completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

103 Early Hints

Let the browser start fetching critical resources while the server is still building the page. A shop that thinks for 400 ms, recorded in Chrome over a real round trip: no hints, a preconnect, preloads, a stale hint, files that can't be cached, HTTP/1.1, a repeat visit and a flushed head, and what each does to TTFB, FCP and LCP.

By the end, you can
  • 01
    Explain a 103Say what the server sends while it thinks, and what the browser does with it before the page arrives.
  • 02
    Choose what to hintPreload files every visit needs, preconnect to servers when you don't know the file, and skip the rest.
  • 03
    Measure it honestlyTTFB counts the 103, so judge the gain by FCP, LCP and when the 200 really arrived.

Using the server's think time

Asha taps a link to the Mint tea shop. Her browser sends the request. Then nothing happens for a while. The shop's server needs 400 ms to build her page. All that time, the connection between them sits silent.

Chrome's guide to Early Hints calls this wait server think-time. In its words, "the connection effectively goes idle for as long as it takes the server to prepare the response". Idle means doing nothing.

Yet the browser could be busy. Every page on the shop needs the same stylesheet and the same big photo. A 103 Early Hints response is a short message the server sends before the page. It tells the browser the names of those files. Then the browser can fetch them while the server thinks. Faster server responses filled this wait by sending the top of the page early. A 103 fills it without sending any of the page. So how much did it help Asha?

How the course timed it

The course built the shop and timed it in Chrome:

  • The shop lives at shop.test. Its page is small: a heading, one big photo, and a link. Its stylesheet, /main.css, lives on the shop too.
  • The big photo near the top is the hero. It lives on an image CDN at cdn.test. A CDN, a content delivery network, is a service that hosts files like images. Here it's a second server. So the browser needs a second connection.
  • Both servers spoke HTTP/2, over HTTPS.
  • A delay proxy sat in between. It's a small go-between program. It held every message for 50 ms on its way. So each round trip took 100 ms. A round trip is a message there and its answer back. A new connection cost one more round trip, as a TCP handshake would.
  • Chrome loaded each version 5 times. Each time, it started with no open connection and an empty cache. The lesson quotes the middle value, the median.

Here is what the course found, without hints and with them:

The same page, a different paintPop out in the code editor (opens in a new tab)JavaScript
// Asha's visit to the Mint tea shop. Medians of 5 runs, in ms from her click.const noHints = { pageArrived: 708.5, heroPainted: 1032 };const withHints = { pageArrived: 706.4, heroPainted: 748 }; console.log(`Without hints, the page arrived at ${noHints.pageArrived} ms and the hero was painted at ${noHints.heroPainted} ms.`);console.log(`With hints, the page arrived at ${withHints.pageArrived} ms and the hero was painted at ${withHints.heroPainted} ms.`);
  • Line 1 says the times are in milliseconds (ms). They count from Asha's click.
  • Line 2 holds two times without hints. pageArrived is when the page's own response started to arrive. heroPainted is when the hero appeared on the screen.
  • Line 3 holds the same two times when the shop sent a 103 first.
  • Line 5 prints Without hints, the page arrived at 708.5 ms and the hero was painted at 1032 ms.
  • Line 6 prints With hints, the page arrived at 706.4 ms and the hero was painted at 748 ms.

The page arrived at almost the same moment both times. Yet with hints, the hero appeared 284 ms sooner. How can a page that arrives no sooner be painted sooner? Here is the whole visit at once:

Medians of 5 runs, in ms from Asha's click. No hints: the page arrived at 708.5 ms and the hero was painted at 1,032 ms. With hints: the 103 arrived at 304.6 ms, the page at 706.4 ms, and the hero was painted at 748 ms.the page arrivesNo hintswaiting for the pagefetchingLCP 1,032 msWith hintswaitingfetchingLCP 748 mspage at 708.5 ms103 at 304.6 mspage at 706.4 ms
The dashed line is when the page arrived, at the same moment in both visits. Shaded: from when the browser knew which files it needed to when the hero was painted.

Without hints, the browser learned the files' names only when the page arrived. With hints, it learned them at 304.6 ms, from the 103. That was about three round trips after the click. One opened the connection. One set up TLS, the encryption that makes the line private. The third carried the request and brought back the 103. So the browser fetched both files while the shop was still thinking.

Real-life analogyA waiter who tells the bar early

Table 4 orders a curry, and the kitchen needs twenty minutes for it. A good waiter doesn't stand at the kitchen door until it's ready. Passing the bar, they say: "Table 4 will want water and bread." The curry lands. The water is poured and the bread is warm.

In real life: The kitchen cooking the curry
In a page load: The server's think time, building the page
In real life: The waiter's word to the bar
In a page load: The 103, with Link headers that name files
In real life: The bar pouring water and warming bread
In a page load: The browser fetching main.css and the hero
In real life: The curry landing on a table that's ready
In a page load: The 200 arriving, with its files already fetched
The main idea

A 103 Early Hints response is a short message a server sends while it's still building the page. It names files and servers the page will need. The browser starts on them at once. It helps only when the server needs time to think, and only with files the page will surely use.

The course recorded seven versions of the shop. Pick one below. Start with Preload CSS and hero, then try No hints:

Playground: what the shop sent while it thought
EventTime
  1. The 103 arrived304.6 ms
  2. The 200 arrived706.4 ms
  3. First contentful paint748 ms
  4. Largest contentful paint748 ms
  5. main.css asked for106.7 ms
  6. hero.jpg asked for311.3 ms
Try it yourself
Preload CSS and hero, first visit: TTFB 304.6 ms, LCP 748 ms

Medians of 5 runs. Each round trip took 100 ms, and the shop thought for 400 ms before it sent the page. The first four rows count from Asha's click. The other rows count from when the shop got her request: each one is when a server was asked for a file.

Recorded in Chrome 156 on Apple M1 Pro. The shop and its image CDN ran on separate HTTP/2 servers. Each sat behind a proxy that held every message for half a round trip.

How 103 Early Hints works

Every HTTP response starts with a three-digit status code. For example, 200 means OK: here is your page. But a server may send short messages first. It sends them while it works on the real answer.

A response before the response

Those early messages use the 1xx status codes, from 100 to 199. RFC 9110 is the standard for HTTP. It calls them "an interim response": one for the meantime. It comes before the final response. The final response is the real answer, like the 200 with the page.

An interim response is only headers: the labelled lines that come before a response's content. RFC 9110 says it "cannot contain content or trailers." So a 103 can't carry any of the page.

RFC 8297 defines 103 Early Hints. Here, the client is the browser. A 103 "indicates to the client that the server is likely to send a final response with the header fields included in the informational response". In plain words: "here are some headers my real answer will probably have."

The hints are Link headers

The header that matters is Link. Each value names a file or a server. Its rel part says what to do with it. A browser acts on two kinds:

  • preload means: fetch this file now. The shop's 103 had two: </main.css>; rel=preload; as=style and <https://cdn.test/hero.jpg>; rel=preload; as=image. The address goes between < and >. The as part says what kind of file it is.
  • preconnect means: open a connection to this server now. <https://cdn.test>; rel=preconnect names a server, not a file. The browser opens the connection, handshakes and all. Then it's ready when a file from that server is needed.

Chrome uses only these two. Its guide says Chrome "Only supports preconnect and preload (that is, prefetch isn't supported)".

You met preload as an HTML tag in HTML parsing and the preload scanner. There, a helper skimmed the recipe for ingredients. During the think time, there's no recipe to skim yet. A 103 hands the helper a shopping list before the recipe comes. As Chrome's guide puts it, "For Early Hints, you won't have the HTML".

Two arrivals in one visit

The browser writes down both responses in the page's navigation entry. That's the record of timings from The navigation timeline. Two fields matter here:

  • firstInterimResponseStart is when the first interim response, the 103, started to arrive.
  • finalResponseHeadersStart is when the final response's headers, the 200's, started to arrive.
The 103, then the 200Pop out in the code editor (opens in a new tab)JavaScript
// Asha's visit with a 103, from the page's navigation entry. Medians of 5 runs, in ms from her click.const entry = { firstInterimResponseStart: 304.6, finalResponseHeadersStart: 706.4 }; console.log(`The 103 arrived at ${entry.firstInterimResponseStart} ms.`);console.log(`The 200 arrived at ${entry.finalResponseHeadersStart} ms.`);console.log(`The browser had ${(entry.finalResponseHeadersStart - entry.firstInterimResponseStart).toFixed(1)} ms in between.`);
  • Line 2 holds the two recorded times, from the visits where the shop sent a 103.
  • Lines 4 and 5 print The 103 arrived at 304.6 ms. and The 200 arrived at 706.4 ms.
  • Line 6 subtracts one from the other. It prints The browser had 401.8 ms in between.

The 103 left the shop the moment Asha's request arrived. The 200 left when the shop had finished thinking. So the gap is the think time, handed to the browser. The longer your server thinks, the more a 103 can fill.

The browser's rules

Chrome's guide and the HTML standard list the rules browsers follow:

  • Navigations only. Early Hints are "Only available for navigation requests". Those are requests for the page itself, not for its files.
  • The first 103 only. The HTML standard: "Only the first early hint response served during the navigation is handled".
  • Not after a redirect to another site. An origin is the scheme, host and port of an address. If the final response redirects to another origin, the hints are "discarded". Redirects covers redirects.
  • Through the HTTP cache. Preloaded files "are stored in the HTTP cache and retrieved from there by the page later". The next section shows why that matters.
  • Hints, not orders. RFC 8297: they "do not replace the header fields on the final response". The page must still link its own stylesheet and hero.

What to hint

First, check that there's a wait to fill. Chrome's guide warns: "Early Hints are not useful if your server can send a 200 (or other final responses) right away". If yours can, use the usual preload or preconnect instead. Put them in the 200's Link header or in the HTML.

Real-life analogyNothing to fill at the sandwich counter

At a sandwich counter, your sandwich is made while you watch. A waiter who ran ahead to the bar would get back after the sandwich did. So nobody runs. You just ask for water with your order.

In real life: A sandwich made while you watch
In a page load: A server that sends its 200 right away
In real life: A waiter running ahead to the bar
In a page load: A 103 sent first
In real life: Asking for water with your order
In a page load: preload or preconnect in the 200's Link header, or in the HTML

The shop thinks for 400 ms, so it has a wait to fill. Which files, then? Chrome's guide suggests two steps:

  1. Start with your landing pages, where visits begin. Chrome says "the usefulness of Early Hints decreases as the user navigates around your website". That's because more of the files are already cached.
  2. Hint what the first paint waits for. Chrome names "render-blocking subresources such as synchronous JavaScript, stylesheets, or even web fonts". Hint the servers that host them too. A render-blocking file must arrive before anything is painted. You saw this in Render-blocking and parser-blocking resources.

Preload: the files every visit needs

The shop's page needs two files before it looks right: the stylesheet and the hero. They're the same on every visit. So the course preloaded both. This time the clock is the servers'. It shows when they were asked for each file, counted from when the shop got Asha's request.

When the servers were asked for the filesPop out in the code editor (opens in a new tab)JavaScript
// When the servers were asked for each file, in ms after the shop got Asha's request. Medians of 5 runs.const noHints = { stylesheet: 511.3, hero: 716.2 };const preloads = { stylesheet: 106.7, hero: 311.3 }; console.log(`Without hints: the stylesheet at ${noHints.stylesheet} ms, the hero at ${noHints.hero} ms.`);console.log(`With preloads: the stylesheet at ${preloads.stylesheet} ms, the hero at ${preloads.hero} ms.`);
  • Line 2 holds the times without hints. Line 3 holds them with preloads.
  • Line 5 prints Without hints: the stylesheet at 511.3 ms, the hero at 716.2 ms.
  • Line 6 prints With preloads: the stylesheet at 106.7 ms, the hero at 311.3 ms.

Without hints, nothing was asked for until the page reached Chrome. That came after the shop's 400 ms of thinking. The hero came about two round trips after the stylesheet. The CDN needed a new connection first. You saw its handshakes in TCP connections and slow start and TLS without the wait.

With preloads, the stylesheet was asked for about one round trip in. That's the 103 travelling to Chrome, and the request coming back. The hero again came two round trips later. Both were asked for before the shop finished thinking. Why it matters: when the page arrived, both files were already coming.

Preconnect: the server, when you don't know the file

Sometimes you know the server a file comes from, but not the file. Say the hero depends on what the server finds in its database. Chrome wants hints "largely independent of the outcome for the main resource". So you can't preload that hero. But you can connect to its server. It's like telling the bar "table 4 will want drinks" before they've chosen which. The course sent a 103 with only <https://cdn.test>; rel=preconnect:

A preconnect to the CDNPop out in the code editor (opens in a new tab)JavaScript
// The same clock, with a 103 that only said: connect to the CDN. Medians of 5 runs.const preconnect = { stylesheet: 515.7, hero: 514.3 }; console.log(`With a preconnect: the stylesheet at ${preconnect.stylesheet} ms, the hero at ${preconnect.hero} ms.`);
  • Line 2 holds the two recorded times, on the same clock as before.
  • Line 4 prints With a preconnect: the stylesheet at 515.7 ms, the hero at 514.3 ms.

The stylesheet came no sooner. Chrome still had to read the page to find it. But the hero was asked for together with it, on a connection that was already open. That saved 201.9 ms on the hero. Here are all three versions, file by file:

Medians of 5 runs: when the servers were asked for each file, in ms after the shop got the page request; the shop thought for 400 ms. No hints, main.css: 511.3 ms. No hints, hero.jpg: 716.2 ms. Preconnect the CDN, main.css: 515.7 ms. Preconnect the CDN, hero.jpg: 514.3 ms. Preload CSS and hero, main.css: 106.7 ms. Preload CSS and hero, hero.jpg: 311.3 ms.No hints, main.css511.3 msNo hints, hero.jpg716.2 msPreconnect the CDN, main.css515.7 msPreconnect the CDN, hero.jpg514.3 msPreload CSS and hero, main.css106.7 msPreload CSS and hero, hero.jpg311.3 ms
Each bar ends when a server was asked for the file, counted from when the shop got Asha's request. Shaded: asked for while the shop was still thinking, before the dashed line at 400 ms.

What Asha saw

Asking sooner only matters if the page appears sooner. First Contentful Paint (FCP) is when the first text or image appears. Largest Contentful Paint (LCP) is when the largest one does: here, the hero.

When each version paintedPop out in the code editor (opens in a new tab)JavaScript
// What Asha saw, in ms from her click. Medians of 5 runs.const visits = {  "No hints": { fcp: 828, lcp: 1032 },  "A preconnect": { fcp: 848, lcp: 848 },  "Preloads": { fcp: 748, lcp: 748 },}; for (const [hint, paint] of Object.entries(visits)) console.log(`${hint}: first paint at ${paint.fcp} ms, hero at ${paint.lcp} ms`);
  • Lines 3 to 5 hold each version's two paint times.
  • Line 8 prints one line for each version. First No hints: first paint at 828 ms, hero at 1032 ms.
  • Then A preconnect: first paint at 848 ms, hero at 848 ms and Preloads: first paint at 748 ms, hero at 748 ms.

Preloads did best. The text and the hero appeared together, 284 ms before the hero did with no hints. The preconnect helped only the hero. The stylesheet was still asked for after the page arrived. Why it matters: preload the files you know, and preconnect when you only know the server.

Hints that cost more than they save

A hint makes the browser spend data before it has seen the page. If the guess is wrong, that work is wasted.

Real-life analogyTelling the bar the wrong drinks

The waiter tells the bar: "Table 4 will want two lemonades." But table 4 has changed its mind. It wants tea. The bar pours the lemonades anyway, and nobody drinks them. The tea is only made when the real order comes. It's as if the waiter had said nothing.

In real life: Two lemonades nobody drinks
In a page load: A hinted file the page doesn't use
In real life: The lemons and time spent pouring them
In a page load: The data spent downloading that file
In real life: The tea, made only when the real order comes
In a page load: The right file, fetched only once the page names it

The course tried three ways. The first is a stale hint, one that's out of date. A site can put a version or a hash in a file's name, like main.fa231e9c.css. A hash is a short code worked out from the file's content. Then the name changes with every version. Chrome warns such files "may not be the best choice". The name in the 103 can fall behind the name in the page. The course's 103 named main.v1.css. That's a stylesheet the page no longer links:

A stale hintPop out in the code editor (opens in a new tab)JavaScript
// A stale hint: the 103 named main.v1.css, but the page now links main.css. The same in all 5 runs.const sent = ["main.v1.css", "main.css", "hero.jpg"];const namedInThePage = ["main.css", "hero.jpg"]; const wasted = sent.filter((file) => !namedInThePage.includes(file));console.log(`The servers sent ${sent.length} files.`);console.log(`Sent for nothing: ${wasted.join(", ")}.`);
  • Line 2 lists the files the servers sent, in order. The list was the same in all 5 runs.
  • Line 3 lists the files the page itself names.
  • Line 5 keeps the files that were sent but aren't named in the page.
  • Lines 6 and 7 print The servers sent 3 files. and Sent for nothing: main.v1.css.

The hero was painted at 1,032 ms, exactly as with no hints. The wrong file cost a download and saved nothing. Chrome says this is "best dealt with automation or templating". So let your build write the hints. Then the names can't drift apart.

The second is a file that can't be cached. A preloaded file waits in the HTTP cache until the page asks for it. So "only cacheable resources can be preloaded using Early Hints or the resource will be double fetched". The course sent both files with Cache-Control: no-store. RFC 9111 says that means a cache "MUST NOT store any part" of the response. Here is what happened:

Files that couldn't be cachedPop out in the code editor (opens in a new tab)JavaScript
// Preloads of files sent with Cache-Control: no-store. The files the servers sent, the same in all 5 runs.const sent = ["main.css", "hero.jpg", "main.css", "hero.jpg"]; const timesSent = (file) => sent.filter((name) => name === file).length;console.log(`main.css was sent ${timesSent("main.css")} times.`);console.log(`hero.jpg was sent ${timesSent("hero.jpg")} times.`);
  • Line 2 lists the files the servers sent, in order. The list was the same in all 5 runs.
  • Line 4 counts how many times one file appears in that list.
  • Lines 5 and 6 print main.css was sent 2 times. and hero.jpg was sent 2 times.

Each file came twice: once for the hint, and again for the page. Even so, the hero was painted at 852 ms, not 1,032. The wasted fetch had opened the connection to the CDN, like a preconnect. Why it matters: every double download costs your visitors data.

The third is a repeat visit. Asha opened a second page on the shop. Both files were already in her cache:

The second pagePop out in the code editor (opens in a new tab)JavaScript
// Asha's second page on the shop, with both files already in her browser's cache. Medians of 5 runs.const noHints = 744;const withHints = 744; console.log(`Without hints, the hero was painted at ${noHints} ms.`);console.log(`With hints, it was painted at ${withHints} ms.`);
  • Lines 2 and 3 hold when the hero was painted, without and with hints.
  • Lines 5 and 6 print Without hints, the hero was painted at 744 ms. and With hints, it was painted at 744 ms.

The servers sent no files at all. Both came from the cache, so the hints had nothing to speed up. That's why Chrome starts with landing pages. Here are all six versions side by side:

Median LCP of 5 runs, and the files the servers sent. No hints: 1,032 ms, 2 files. Preload: 748 ms, 2 files. A stale hint: 1,032 ms, 3 files. Preload, not cacheable: 852 ms, 4 files. No hints, repeat visit: 744 ms, 0 files. Preload, repeat visit: 744 ms, 0 files.No hints1,032 ms, 2 filesPreload748 ms, 2 filesA stale hint1,032 ms, 3 filesPreload, not cacheable852 ms, 4 filesNo hints, repeat visit744 ms, 0 filesPreload, repeat visit744 ms, 0 files
Each bar ends when the hero was painted (LCP). Shaded: the servers sent more files than the page used.

Browser support for preconnect and preload hints

Each browser decides what it will act on. Chrome's guide: "Although 103 Early Hints is supported in all major browsers, the directives that can be sent on an Early Hint differs per browser". A directive here is the rel value: preconnect or preload.

Real-life analogyA note on the kitchen door

The kitchen pins a note on its back door for the morning deliveries. It says: "Extra bread and extra milk today." The baker reads it and brings more bread. The milkman reads only his own line. The fruit seller never looks at the door. He brings his usual order at his usual time.

In real life: The note on the door
In a page load: The 103 and its Link hints
In real life: The baker, who acts on the whole note
In a page load: Chrome and Firefox: preconnect and preload
In real life: The milkman, who acts on his own line only
In a page load: Safari: preconnect only
In real life: The fruit seller, who walks past
In a page load: A browser that ignores the 103, like Chrome over HTTP/1.1

MDN's compatibility data says who acts on what:

  • preconnect works in Chrome 103, Firefox 120 and Safari 17, and in later versions.
  • preload works in Chrome 103 and Firefox 123, and in later versions. Safari doesn't preload from a 103.

So on Safari, a preload in a 103 does nothing. But a preconnect to the same server still works. Why it matters: when a key file lives on another server, a preconnect to that server reaches more browsers.

Only over HTTP/2 and later

The protocol matters too. MDN's data notes that Chrome and Safari support a 103 "in HTTP/2 and later only". RFC 8297 explains the risk. An old HTTP/1.1 client could mistake the 103 for the real answer. Then it would mix up every response after it on that connection. So "a server might refrain from sending 103 (Early Hints) responses over HTTP/1.1 unless the client is known to handle informational responses correctly". To refrain is to hold back.

The course sent the same 103 to a Chrome kept to HTTP/1.1:

The same 103, two protocolsPop out in the code editor (opens in a new tab)JavaScript
// The same 103 with preloads, to Chrome over HTTP/2 and to a Chrome kept to HTTP/1.1. Medians of 5 runs, in ms.const http2 = { hintsArrived: 304.6, heroPainted: 748 };const http1 = { hintsArrived: 306.1, heroPainted: 1032 }; console.log(`Over HTTP/2, the 103 arrived at ${http2.hintsArrived} ms. The hero was painted at ${http2.heroPainted} ms.`);console.log(`Over HTTP/1.1, the 103 arrived at ${http1.hintsArrived} ms. The hero was painted at ${http1.heroPainted} ms.`);
  • Lines 2 and 3 hold one protocol each. They say when the 103 arrived and when the hero was painted.
  • Line 5 prints Over HTTP/2, the 103 arrived at 304.6 ms. The hero was painted at 748 ms.
  • Line 6 prints Over HTTP/1.1, the 103 arrived at 306.1 ms. The hero was painted at 1032 ms.

Over HTTP/1.1, Chrome wrote down when the 103 arrived. Then it ignored its hints. On the shop's clock, the stylesheet was asked for only at 510.4 ms, after the page. That's the fruit seller walking past the note. Why it matters: over HTTP/1.1, a 103 won't help visitors on Chrome or Safari.

CDN and server support

Now the sending side. Which servers can send a 103? Chrome's guide lists these open-source web servers:

  • Apache, with its mod_http2 module.
  • H2O.
  • NGINX.
  • Node.js, in both its http and http2 modules.

It also lists CDNs that offer Early Hints: Cloudflare, Fastly and Akamai. With one in front of your server, "you may not need to manually implement Early Hints". To learn what yours does, Chrome says to "Refer to your solution provider's online documentation".

The shop's server

The course's shop is a Node.js server. Node's response.writeEarlyHints() "Sends a status 103 Early Hints to the client with a Link header". Here is the shop's function that answers a page request:

The shop: hints first, the page when it's readyJavaScript
async function shopPage(request, response, hints) {  if (request.headers["sec-fetch-mode"] === "navigate") response.writeEarlyHints({ link: hints });  const html = await buildPage();  response.writeHead(200, { "content-type": "text/html", link: hints }).end(html);}
  • Line 1 starts the function. hints is the list of Link values, like </main.css>; rel=preload; as=style.
  • Line 2 sends the 103 with those hints, but only if the request is a navigation.
  • Line 3 builds the page. This is the 400 ms of think time.
  • Line 4 sends the 200 with the page. It repeats the hints in its own Link header.

How does line 2 spot a navigation? The browser sends a Sec-Fetch-Mode header. It "exposes a request's mode to a server". The mode says what kind of request it is. The mode navigate is "used only when navigating between documents". Chrome's guide gives two reasons to check it:

  • Browsers use Early Hints only for navigations. So a 103 on any other request is wasted.
  • "Some older clients or bots may struggle with these". A bot is a program that visits pages on its own. Clients that send sec-fetch-mode: navigate are newer ones. They "understand to wait for the subsequent response".

Line 4 follows Chrome's advice too. It says: "For backward compatibility, it's good practice to also include Link HTTP headers in the final response". Backward compatibility means still working with older browsers. So a browser that ignored the 103 still gets the hints. Chrome also says Early Hints "are recommended to only be sent over HTTP/2 or HTTP/3 connections". The HTTP/1.1 recording showed why.

Not HTTP/2 Push again

HTTP/2 has a feature called server push. Chrome's guide calls it deprecated: no longer recommended, and on its way out. With push, the server sends files along with the page, without being asked. You saw it in HTTP/1.1 and HTTP/2. It's like a bar sending drinks to every table, even tables that already have some. Chrome: "Chrome data showed that HTTP2/Push was in fact a net negative for performance across the web". A net negative means it made pages slower overall.

A 103 sends only names. So the browser skips what's already in its cache, as on Asha's second page. RFC 8297 names the price: "one extra round trip compared to server push in the worst case". On the shop, that's the round trip before the stylesheet was asked for, at 106.7 ms.

Measuring the gain

The shop added a 103, and the hero appeared 284 ms sooner. How would you see that in your own numbers? Not in TTFB, it turns out.

TTFB counts the 103

Time to first byte (TTFB) is the wait from the start of a visit to the response's first byte. You met it in Time to first byte. With a 103, which response counts? web.dev: "The 103 Early Hints counts as the 'first bytes'."

Real-life analogyA stopwatch that stops at the waiter's word

Your friend times table 4's curry. They stop the stopwatch at the first sign of it. After a minute, the waiter tells the bar what table 4 will want. Click: "One minute, great service!" But the curry took as long as ever.

In real life: The stopwatch stopping at the waiter's word to the bar
In a page load: TTFB: responseStart, the 103's time
In real life: The curry landing
In a page load: The 200: finalResponseHeadersStart
In real life: The meal you came for
In a page load: LCP: the hero on the screen

In Time to first byte, the stopwatch stopped at the bread. Here it stops even earlier. TTFB is read from responseStart. The Resource Timing standard says responseStart is "firstInterimResponseStart if it is not 0; Otherwise this's finalResponseHeadersStart". So with a 103, it's the 103's time.

Chrome 115 tried measuring the final response instead. But web.dev says that change "was reverted in Chrome 133". Reverted means undone. So TTFB moves with the 103:

Three numbers, one gainPop out in the code editor (opens in a new tab)JavaScript
// Medians of 5 runs, in ms from Asha's click: TTFB, when the 200's headers arrived, and LCP.const noHints = { ttfb: 708.5, finalHeaders: 708.5, lcp: 1032 };const withHints = { ttfb: 304.6, finalHeaders: 706.4, lcp: 748 }; console.log(`TTFB came ${(noHints.ttfb - withHints.ttfb).toFixed(1)} ms sooner.`);console.log(`The 200 came ${(noHints.finalHeaders - withHints.finalHeaders).toFixed(1)} ms sooner.`);console.log(`LCP came ${noHints.lcp - withHints.lcp} ms sooner.`);
  • Line 1 names the three times. finalHeaders is finalResponseHeadersStart, when the 200 arrived.
  • Line 2 holds them without hints. Line 3 holds them with the 103.
  • Line 5 prints TTFB came 403.9 ms sooner.
  • Line 6 prints The 200 came 2.1 ms sooner.
  • Line 7 prints LCP came 284 ms sooner.

TTFB says the page got about 400 ms faster. It didn't. The 200 came at the same moment. The real gain was 284 ms of LCP. TTFB can even fall with no gain at all. Over HTTP/1.1, Chrome ignored the hints. Yet TTFB fell just as far, to 306.1 ms.

Medians of 5 runs. No hints, TTFB: 708.5 ms. No hints, LCP: 1,032 ms. 103, HTTP/2, TTFB: 304.6 ms. 103, HTTP/2, LCP: 748 ms. 103, HTTP/1.1, TTFB: 306.1 ms. 103, HTTP/1.1, LCP: 1,032 ms. Head flushed, TTFB: 306.7 ms. Head flushed, LCP: 736 ms.No hints, TTFB708.5 msNo hints, LCP1,032 ms103, HTTP/2, TTFB304.6 ms103, HTTP/2, LCP748 ms103, HTTP/1.1, TTFB306.1 ms103, HTTP/1.1, LCP1,032 msHead flushed, TTFB306.7 msHead flushed, LCP736 ms
Plain bars: TTFB. Shaded bars: LCP. TTFB fell with every 103, even over HTTP/1.1, where LCP didn't move.

So judge a 103 by three things:

  • FCP and LCP: the paints Asha sees.
  • finalResponseHeadersStart: when the 200 really arrived.
  • Which files came from the hints.

Which files came from the hints?

Resource Timing keeps a record, an entry, for each file a page loads. Each entry has an initiatorType that says what asked for the file. It's "early-hints" when the request "is the result of processing an Early Hints response". This code lists those files on any page:

Which files came from the 103?JavaScript
const hinted = performance
  .getEntriesByType("resource")
  .filter((entry) => entry.initiatorType === "early-hints")
  .map((entry) => entry.name);

console.log(hinted);
  • Line 1 starts a list called hinted, built by lines 2 to 4.
  • Line 2 gets the Resource Timing entry of every file the page loaded.
  • Line 3 keeps the ones whose initiatorType is "early-hints".
  • Line 4 keeps just their addresses. Line 6 logs the list.

On every visit with preloads, both of the shop's files were marked "early-hints". Their transferSize was 0. The spec reports 0 bytes for a file from the local cache. On the stale-hint visits, main.v1.css was marked too. So the list also shows hints the page never used.

A 103 or a flushed head?

Faster server responses used the think time another way. It flushed the head. To flush is to send what's ready so far, before the rest is done. The head, the top of the page, held the stylesheet and the hero's preload. The course tried that here too:

Two ways to use the think timePop out in the code editor (opens in a new tab)JavaScript
// Two ways to use the think time. Medians of 5 runs, in ms from Asha's click.const hints = { firstByte: 304.6, heroPainted: 748 };const flushedHead = { firstByte: 306.7, heroPainted: 736 }; console.log(`A 103: first byte at ${hints.firstByte} ms, hero painted at ${hints.heroPainted} ms.`);console.log(`A flushed head: first byte at ${flushedHead.firstByte} ms, hero painted at ${flushedHead.heroPainted} ms.`);
  • Lines 2 and 3 hold one way each. They say when the first byte came and when the hero was painted.
  • Line 5 prints A 103: first byte at 304.6 ms, hero painted at 748 ms.
  • Line 6 prints A flushed head: first byte at 306.7 ms, hero painted at 736 ms.

The two did about as well. web.dev calls early flushing "similar in effect to Early Hints." So why have both? Because a flush sends the 200 before the work is done. A 103 leaves the final answer open. RFC 8297 notes that "the status code might depend on the result of a database query".

Say Asha asks for a tea the shop no longer sells. After a flush, the shop has already said 200. After a 103, it can still answer 404: not found. A flush is the starter already on the table. A 103 is only the waiter's word to the bar. Each row below asks a question. Compare the two answers:

A 103 and a flushed head
The questionA 103A flushed head
What leaves the server at once?Only headers: the Link hints. No part of the page.The 200, its headers, and the page's <head>.
Can the server still choose the status code?Yes. The final response hasn't started.No. The 200 has already left.
What does TTFB show?When the 103 arrived: 304.6 ms.When the 200 and the head arrived: 306.7 ms.
When was the hero painted?At 748 ms.At 736 ms.

Hint it or not?

For each candidate, ask: will the page surely use it? Can it be cached? Do you know the file, or only its server? Then sort it:

Hint it or not?
  • /main.css, the same stylesheet on every page
  • The landing page's hero image, cached for an hour
  • The image CDN, when which hero to show depends on what the server finds
  • The image CDN, for visitors on Safari
  • A stylesheet sent with Cache-Control: no-store
  • A hashed file name, typed into the server's settings by hand
  • A page the server can send in a few milliseconds
Try it yourself
0 of 7 correct

Sort each candidate by what to do with it in a 103.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A lower TTFB proves Early Hints worked." No. TTFB stops at the 103. Over HTTP/1.1, it fell to 306.1 ms while LCP stayed at 1,032 ms.
  • "A 103 makes the page arrive sooner." No. The 200 came at the same moment. Only its files came sooner.
  • "Hints are free." No. A stale hint cost a download, and files that couldn't be cached came twice.
  • "You can hint any file, any way." No. Only files that can be cached, with preload or preconnect.
  • "Early Hints is HTTP/2 Push again." No. Push sent files. A 103 sends only names.
  • "Every browser preloads from a 103." No. Safari acts only on preconnect.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA stale hint

This 103 named an old stylesheet instead of the real files. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// The same clock, with a 103 that named main.v1.css, a stylesheet the page no longer links. Medians of 5 runs.
const noHints = { stylesheet: 511.3, hero: 716.2 };
const staleHint = { stylesheet: 510.3, hero: 712.8 };

console.log(`Without hints: the stylesheet at ${noHints.stylesheet} ms, the hero at ${noHints.hero} ms.`);
console.log(`With a stale hint: the stylesheet at ${staleHint.stylesheet} ms, the hero at ${staleHint.hero} ms.`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upTTFB over HTTP/1.1

    The same 103 went to a Chrome kept to HTTP/1.1. What does the first line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // The same 103, but Chrome spoke HTTP/1.1. Medians of 5 runs, in ms from Asha's click.
    const noHints = { ttfb: 708.5, finalHeaders: 708.5, lcp: 1032 };
    const withHints = { ttfb: 306.1, finalHeaders: 706.4, lcp: 1032 };
    
    console.log(`TTFB came ${(noHints.ttfb - withHints.ttfb).toFixed(1)} ms sooner.`);
    console.log(`The 200 came ${(noHints.finalHeaders - withHints.finalHeaders).toFixed(1)} ms sooner.`);
    console.log(`LCP came ${noHints.lcp - withHints.lcp} ms sooner.`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe page, with a preconnect

      With a preconnect to the CDN, when did the 200 arrive, in ms from the click?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAsked for twice

        When the files couldn't be cached, when was main.css asked for the second time?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeTesting in DevTools

          You test the shop with DevTools open, and the page doesn't use the files the 103 preloaded. Chrome's Early Hints guide names a checkbox in the Network panel that must not be ticked. Which one?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA hero that picks its size

            The shop's hero picks its size for the screen, with imagesrcset and imagesizes on its preload. Can a 103 preload it before the page arrives? Answer yes or no.

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask three things. Did the server have time to spare? Could the browser use the hint? Which number shows the real gain?

              103 Early Hints quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the second line of the measuring example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Medians of 5 runs, in ms from Asha's click: TTFB, when the 200's headers arrived, and LCP.
                const noHints = { ttfb: 708.5, finalHeaders: 708.5, lcp: 1032 };
                const withHints = { ttfb: 304.6, finalHeaders: 706.4, lcp: 748 };
                
                console.log(`TTFB came ${(noHints.ttfb - withHints.ttfb).toFixed(1)} ms sooner.`);
                console.log(`The 200 came ${(noHints.finalHeaders - withHints.finalHeaders).toFixed(1)} ms sooner.`);
                console.log(`LCP came ${noHints.lcp - withHints.lcp} ms sooner.`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the last line of the stale-hint example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // A stale hint: the 103 named main.v1.css, but the page now links main.css. The same in all 5 runs.
                const sent = ["main.v1.css", "main.css", "hero.jpg"];
                const namedInThePage = ["main.css", "hero.jpg"];
                
                const wasted = sent.filter((file) => !namedInThePage.includes(file));
                console.log(`The servers sent ${sent.length} files.`);
                console.log(`Sent for nothing: ${wasted.join(", ")}.`);

                Choose an answer to see the explanation.

              3. Question 3 of 7A server can send its whole page 20 ms after the request arrives. Should it send a 103 first?

                Choose an answer to see the explanation.

              4. Question 4 of 7Chrome loads a page over HTTP/1.1 and gets a 103 with preload hints. What happens?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why were hinted files sent with Cache-Control: no-store downloaded twice?

                Choose an answer to see the explanation.

              6. Question 6 of 7The hero image's name depends on what the server finds in its database. What can the 103 still do for it?

                Choose an answer to see the explanation.

              7. Question 7 of 7After you add a 103, field TTFB falls by 400 ms. What should you check next?

                Choose an answer to see the explanation.

              Key takeaways

              • A 103 comes before the page. It names files and servers, so the browser starts on them while the server thinks.
              • It only helps when the server needs time. A page sent at once needs no 103.
              • Preload files every visit needs, with stable names, that can be cached. Preconnect when you only know the server.
              • Send it only for navigations, over HTTP/2 or HTTP/3, and repeat the Link headers in the 200.
              • Safari acts only on preconnect. Chrome and Safari ignore a 103 over HTTP/1.1.
              • TTFB counts the 103. Judge the gain by FCP, LCP and finalResponseHeadersStart.

              Remember the one-liner.
              Hint what the page surely needs while the server thinks, and measure the paint, not the first byte.

              Sources

              The facts on this page come from these standards and docs:

              Coming next: HTTP caching in depth, also in Fast servers, caches, and networks. It covers Cache-Control, freshness and revalidation: how a browser decides to reuse a response. That's the cache the shop's hinted files waited in.

              CompleteFrontend Clear concepts. Working examples.