Enabling 103 Early Hints on Cloudflare for Hero Images

Cloudflare will not invent a hint for you. Its Early Hints implementation is a harvest-and-replay cache: the edge records the Link headers it observes on a 200 response for a URL, stores them, and replays that stored set as a 103 informational response to subsequent requests for the same URL — before it has even contacted your origin. If your HTML never carries a Link header, turning the feature on changes nothing at all, and the dashboard will happily report it as enabled. This page is the exact configuration and verification path for getting a hero image hinted, and sits under Early Hints and Speculation Rules for Media, which covers the wire format and the browser-side model.

Two Cloudflare-specific properties drive every operational decision here. The hint store is per data centre, so the first visitor to hit a cold colo gets no 103 and your lab test can fail purely by geography. And the store is keyed by the same cache key as the page, which means a hint harvested from one variant of a URL can be replayed to a visitor who will be served a different one.

Prerequisite checklist

How the harvest-and-replay cycle actually runs

The sequence matters because it explains the two behaviours that make people give up on the feature: the first request never gets a hint, and a request routed through a different data centre does not either.

Cloudflare Early Hints: Harvest Phase and Replay Phase A three-lane sequence diagram split into two phases. In the harvest phase, the first visitor in a cold data centre requests the page, the edge forwards to origin, the origin returns a 200 with a Link preload header, and the edge stores that header set before returning the 200 with no 103. In the replay phase, a later visitor to the same data centre gets a 103 Early Hints response immediately from the stored set, starts the hero fetch, and only then does the edge go to origin for the HTML. Browser Cloudflare colo (AMS) Origin Phase 1 — harvest (visitor A, cold store) GET /product/kayak miss — forward to origin 200 OK + Link: </hero.avif>; rel=preload store hint set for this key 200 OK — no 103 for A Phase 2 — replay (visitor B, same colo) GET /product/kayak 103 Early Hints — 0 origin round trips …origin still rendering… GET /hero.avif starts now The store is per data centre: visitor B routed to LHR instead of AMS repeats phase 1. Expect partial field coverage, never 100%. Only rel=preload and rel=preconnect are harvested — every other rel on the 200 is ignored for replay purposes.

The practical consequence is that you cannot make the hint appear by asking nicely once. Every verification below is written as warm it, then measure it, and every field-data reading has to be interpreted as a mixture of hinted and unhinted navigations.

It also means the hint is decoupled from the HTML cache. A page served cf-cache-status: DYNAMIC — uncacheable, revalidated on every request, personalised below the fold — still receives a 103 once the colo has observed one qualifying 200, because the hint set lives in its own store rather than inside the cached object. That is what makes the feature useful on server-rendered applications, which are precisely the ones with the origin think time worth filling. The table below is the behaviour worth internalising before you debug anything:

Behaviour Cloudflare Early Hints
Source of the hint Link headers observed on a previous 200 for the same cache key
Rel values replayed preload and preconnect only
Requires the HTML to be cacheable No — DYNAMIC responses are hinted too
Scope of the store Per data centre, per cache key
First request to a cold colo Harvest only, no 103
Effect of a cache purge on that URL Stored hint set is dropped with the object
Protocol requirement HTTPS with HTTP/2 or HTTP/3; ignored on HTTP/1.x
Per-route toggle None — zone-level switch, scope with rule expressions

Nothing in that table is negotiable from the dashboard, so the whole configuration reduces to two questions: which routes emit a Link header, and is the header correct enough for the browser to reuse the fetch.

The full configuration

One script does everything: it writes the Link header with a Response Header Transform Rule (so you need no origin deploy), enables the zone setting, and warms the store. Transform Rules run at the edge on the response path, and Cloudflare harvests the header after the rule has applied — which is exactly why this works without touching your application.

#!/usr/bin/env bash
set -euo pipefail
# Requires: ZONE_ID, CF_API_TOKEN in the environment. Token scopes:
#   Zone → Zone Settings → Edit   (for the early_hints toggle)
#   Zone → Config Rules / Transform Rules → Edit (rulesets API)
API="https://api.cloudflare.com/client/v4/zones/${ZONE_ID}"
AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json")

# ── 1. Emit the Link header on the HTML response ──────────────────────────────
# Phase http_response_headers_transform runs on every response leaving the edge.
# PUT on the entrypoint REPLACES the whole rule list for the phase — fetch and
# merge first if you already have response-header rules in this zone.
curl -sS -X PUT "${API}/rulesets/phases/http_response_headers_transform/entrypoint" \
  "${AUTH[@]}" --data @- <<'JSON' | jq -r '.success, .errors'
{
  "rules": [
    {
      "description": "Early Hints: preload the home hero",
      "expression": "(http.request.uri.path eq \"/\" and http.response.code eq 200)",
      "action": "rewrite",
      "action_parameters": {
        "headers": {
          "Link": {
            "operation": "add",
            "value": "</cdn-cgi/image/width=1600,format=auto,quality=72/media/home-hero.jpg>; rel=preload; as=image; fetchpriority=high; imagesrcset=\"/cdn-cgi/image/width=800,format=auto,quality=72/media/home-hero.jpg 800w, /cdn-cgi/image/width=1600,format=auto,quality=72/media/home-hero.jpg 1600w\"; imagesizes=\"(max-width: 800px) 100vw, 1200px\""
          }
        }
      }
    },
    {
      "description": "Early Hints: warm the media origin connection",
      "expression": "(starts_with(http.request.uri.path, \"/product/\") and http.response.code eq 200)",
      "action": "rewrite",
      "action_parameters": {
        "headers": {
          "Link": {
            "operation": "add",
            "value": "<https://media.example.com>; rel=preconnect; crossorigin"
          }
        }
      }
    }
  ]
}
JSON
# operation "add"  → appends, keeping any Link header the origin already sent.
# operation "set"  → replaces them all; use it only when the origin is untrusted.
# http.response.code guard → never hint on a 301/404; the hinted bytes would be
#                            fetched for a page the visitor never sees.

# ── 2. Turn on harvest-and-replay ─────────────────────────────────────────────
# Zone-wide. There is no per-route toggle; scope with the rule expressions above.
curl -sS -X PATCH "${API}/settings/early_hints" "${AUTH[@]}" \
  --data '{"value":"on"}' | jq -r '.result.value'

# ── 3. Warm the store, then read it back ──────────────────────────────────────
# First request populates the colo you are routed to; second should show the 103.
URL="https://example.com/"
curl -sS -o /dev/null --http2 "$URL"                 # harvest pass
sleep 2
curl -sSI --http2 "$URL" 2>&1 | grep -iE '^(HTTP/2 103|HTTP/2 200|link:|cf-ray:)'
# Expect, in this order:
#   HTTP/2 103
#   link: </cdn-cgi/image/width=1600,…>; rel=preload; as=image; …
#   HTTP/2 200
#   cf-ray: 8f2c…-AMS      ← the colo whose store you just warmed

Warning: the PUT …/entrypoint call replaces every rule in the response-header phase. In a zone that already has security or CORS header rules, GET the entrypoint first, splice your rule into the returned rules array, and PUT the merged list — or the next deploy will silently delete somebody else’s configuration.

Anatomy of a hint that the browser will actually reuse

A hint that does not match the element byte-for-byte is worse than no hint: the browser fetches the hinted URL, fails to match it to the <img>, and fetches again. Every parameter below exists to make the preload record and the element’s request identical in URL, destination, and CORS mode.

Anatomy of the Harvested Link Header Six stacked chips list the parts of the Link header: the absolute-path URL, rel equals preload, as equals image, imagesrcset, imagesizes, and fetchpriority equals high. Each has an explanation to its right stating what breaks if that part is wrong, such as a duplicate fetch when the destination is missing or the wrong candidate being preloaded when imagesizes disagrees with the markup. Every field is a matching key — a mismatch costs one extra full-size image download </cdn-cgi/image/width=1600,…/hero.jpg> Absolute path, angle brackets required. Must be the resized URL the markup uses. rel=preload One of only two rels Cloudflare replays. rel=prefetch in a 103 is ignored by Chromium. as=image Omit it and the record has no destination: the hero downloads twice, LCP unchanged. imagesrcset="… 800w, … 1600w" Lets the hint pick the same candidate the <img srcset> will pick on this viewport. imagesizes="(max-width:800px) 100vw, 1200px" Must equal the sizes attribute exactly. Disagree and you preload the wrong width. fetchpriority=high Honoured in Link headers by Chromium; harmlessly ignored elsewhere. Not shown: crossorigin. Add it only if the <img> carries crossorigin — a mismatch partitions the cache and double-fetches. Keep the whole hint set to one or two assets. Anything hinted competes with the HTML itself for the same connection.

If your hero is served through Cloudflare’s image transforms, the URL in the hint must include the full /cdn-cgi/image/<options>/ prefix — the transform options are part of the path and therefore part of the cache key. format=auto is safe to hint because the URL does not change per format; the preload request carries the same Accept header the <img> would send, so the edge negotiates AVIF or WebP identically. The option syntax is covered in configuring Cloudflare image resizing URL parameters; if you use Polish rather than resizing, the URL is your original path and nothing special is required.

Verification

1. Prove the 103 is on the wire, in a known colo

# -v prints informational responses; --http2 is mandatory to see them at all.
# cf-ray's suffix is the colo code — record it, because the store you warmed
# is that colo's store and nobody else's.
curl -sSv --http2 https://example.com/ -o /dev/null 2>&1 \
  | grep -iE '< HTTP/2 (103|200)|< link:|< cf-ray:'

# Pin a specific edge address so repeated runs hit the same colo. Take one of
# the zone's anycast addresses from dig and force it with --resolve.
IP=$(dig +short example.com | head -1)
for i in 1 2 3; do
  curl -sSv --http2 --resolve "example.com:443:${IP}" https://example.com/ -o /dev/null 2>&1 \
    | grep -c '< HTTP/2 103'
done
# Expect 0 on the first line (harvest) and 1 thereafter (replay).

# The hinted URL must exist. A hint pointing at a 404 costs a round trip on
# every single navigation and never appears in any LCP report.
HINT=$(curl -sSI --http2 https://example.com/ | grep -i '^link:' | sed -E 's/.*<([^>]+)>.*/\1/' | head -1)
curl -sS -o /dev/null -w 'hinted asset → %{http_code}, %{size_download} bytes\n' "https://example.com${HINT}"

2. Confirm the browser adopted the hint instead of re-fetching

A working hint shows up in Chrome DevTools → Network with the Initiator column reading early-hints, and the hero’s request bar starts before the HTML document’s bar ends — a timing that is impossible for any in-document hint. The console check is faster to run repeatedly:

// Resources the 103 actually started, and any URL fetched more than once.
const res = performance.getEntriesByType('resource');
console.table(res.filter(e => e.initiatorType === 'early-hints')
                .map(e => ({ url: e.name, start: Math.round(e.startTime), end: Math.round(e.responseEnd) })));

const seen = new Map();
res.forEach(e => seen.set(e.name, (seen.get(e.name) ?? 0) + 1));
[...seen].filter(([, n]) => n > 1)
         .forEach(([url, n]) => console.warn('DUPLICATE ×' + n, url));

// The candidate the browser actually chose. If this is not the URL you hinted,
// imagesrcset/imagesizes disagree with the markup.
const hero = document.querySelector('img[fetchpriority="high"]');
console.log('currentSrc:', hero?.currentSrc);

3. Measure coverage, not just presence

Because the store is per colo, a single successful curl says nothing about what share of real navigations receive a hint. Tag your RUM beacon with the initiator and read the split; anything under about 70% usually means low traffic per colo or a cache key that fragments the store (a Vary on User-Agent, a marketing query parameter, or a cookie in the key).

// Attach to the LCP beacon: was this navigation hinted, and where from?
const heroEntry = performance.getEntriesByType('resource')
  .find(e => e.name.includes('/media/home-hero'));
navigator.sendBeacon('/rum', JSON.stringify({
  hinted: heroEntry?.initiatorType === 'early-hints',
  heroStart: Math.round(heroEntry?.startTime ?? -1),
  // Server-Timing or a small header echo is the usual way to surface cf-ray
  // to the page; document.referrer-style guessing is not reliable.
  colo: performance.getEntriesByType('navigation')[0]
          ?.serverTiming?.find(s => s.name === 'colo')?.description
}));

For the before/after picture, run the same URL through WebPageTest with the setting toggled and diff the filmstrips rather than trusting a single LCP number — the WebPageTest filmstrip diff automation workflow automates exactly that comparison, and CrUX field data tells you whether the mixture moved the 75th percentile.

The hint store has a lifecycle, and deploys break it

Lifecycle of the Per-Colo Early Hints Store Four states arranged in a loop. Empty moves to Primed when the edge observes a 200 carrying a Link header. Primed moves to Replaying, which self-loops for every later request in that data centre. A deploy that changes the hero filename moves Replaying to Stale, where the hint points at a 404. Purging the URL or letting the entry expire returns Stale to Empty. EMPTY no 103 is sent PRIMED hint set recorded REPLAYING 103 before origin STALE preloading a 404 edge sees a 200 carrying Link: next request in this colo every later hit deploy renames hero-a1b2.avif purge the HTML URL or wait for expiry Every colo runs this machine independently, so a deploy leaves some data centres REPLAYING correctly and others STALE for minutes.

The STALE state is the one that costs real money. Because Cloudflare replays what it observed, a deploy that changes a content-hashed hero filename leaves the edge preloading the previous build’s URL, and every navigation pays a full round trip for a 404 while the real hero is discovered late by the parser. Fix it in the deploy pipeline, not in review:

# Purge the HTML URLs whose hint sets reference build assets. Purging the page
# clears its stored hint association along with the cached body.
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" -H 'Content-Type: application/json' \
  --data '{"files":["https://example.com/","https://example.com/product/kayak"]}'

Because the transform rule is the source of the hint, the more robust pattern is to make the hinted URL stable across deploys — a stable path with content negotiation and a long Cache-Control: immutable on the bytes, per the cache-control strategy for media assets — so the hint never needs to change at all.

Common mistakes

1. Enabling the toggle and expecting hints to appear

Nothing on the origin emits Link by default, and Cloudflare never synthesises one from the HTML body. The symptom is a zone where Early Hints reads on for weeks with zero 103 responses ever observed.

# WRONG — this is the whole change, and it does nothing on its own.
curl -X PATCH ".../settings/early_hints" --data '{"value":"on"}'
# CORRECT — emit the header first (transform rule or origin), then enable.
# Confirm the 200 carries it before you look for a 103:
curl -sSI --http2 https://example.com/ | grep -i '^link:'

2. Hinting a per-visitor hero

The hint is stored against the cache key, and the default cache key does not include cookies, device type, or A/B bucket. A logged-in dashboard hero, a geo-personalised banner, or a 50/50 split test replays one segment’s asset to everyone in that colo — half your Early Hints bandwidth downloads an image that is never painted. Scope the transform rule to anonymous, invariant routes:

WRONG:  (http.response.code eq 200)
CORRECT: (http.response.code eq 200
          and http.request.uri.path in {"/" "/pricing"}
          and not any(http.request.headers["cookie"][*] contains "session="))

3. A Worker that rebuilds the response and drops the header

If a Worker sits on the route, the harvest sees whatever the Worker returns. Constructing a fresh Response without carrying the original headers over silently removes the Link header before the edge ever sees it.

// WRONG — headers are dropped, so nothing is ever harvested.
const res = await fetch(request);
return new Response(res.body, { status: res.status });
// CORRECT — the second argument copies status, statusText AND headers.
// Add the hint here if you need it computed per request rather than per route.
const res = await fetch(request);
const out = new Response(res.body, res);
out.headers.append('Link', '</media/home-hero-1600.avif>; rel=preload; as=image');
return out;

4. Hinting one fixed width against a responsive <img>

Link: </hero-1600.avif>; rel=preload; as=image next to an <img srcset> that picks the 800w candidate on a phone downloads both files — roughly 1.6× the hero bytes on exactly the devices least able to afford them. Carry imagesrcset and imagesizes in the header and keep them byte-identical to the markup; the method for deriving a correct sizes value is in how to calculate optimal sizes attribute values.

5. Treating one green curl as proof

The first request to any colo is a harvest, not a replay, and CI runners in a low-traffic region can see cold stores repeatedly. Assert on the second request, pin the edge IP with --resolve, and record cf-ray so a failure tells you which data centre it came from. In field data, expect a mixture — and read a hinted share below 70% as a signal about traffic distribution or cache-key fragmentation, not as a broken configuration.

The same reasoning applies in reverse when a hint appears to have stopped working. Before touching the configuration, establish which of four things changed: the transform rule stopped matching (check the 200 still carries Link), the cache key fragmented so each variant now needs its own harvest (a new query parameter, a Vary header added upstream), the hinted asset moved (the STALE state above), or you simply landed in a colder colo than last time. Only the first two are configuration problems; the other two are properties of the system and will resolve themselves within minutes of steady traffic.

Tradeoff: every hint spends bandwidth on a prediction made before the origin has decided anything. On a route where the hero is genuinely invariant that prediction is free; on anything personalised it is a tax paid by users who never see the asset. Hint one asset per route, and only where the same bytes are painted for every visitor. The fetchpriority interaction on the browser side is covered in using fetchpriority to optimize critical media.