completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Faster server responses

Find slow back-end work with Server-Timing, cache what you can, and start sending HTML early. A product page with a database query, a recommendations service and a template, measured in Chrome over a real round trip: one after another, in parallel, cached two ways, and with its head flushed first.

By the end, you can
  • 01
    See inside the server's timeSend a Server-Timing entry for each job, and read it in DevTools, on the page and in field data.
  • 02
    Cut the slow pathRun jobs that don't need each other together, and cache the job the server really waits for.
  • 03
    Send something soonerFlush the head, so the browser fetches the stylesheet and the hero while the server works.

Where the server's time goes

Asha taps a link to the mint tea page of her favourite tea shop. The screen stays blank. Almost a second later, the page appears. What was happening in that blank second? And what could the shop do to make it shorter?

The answer starts with a word from the lesson before. Time to first byte, or TTFB, is the time from the start of a visit until the first byte of the page arrives. Time to first byte took it apart. In short, it holds two things: the trip to the server and back, and the time the server spends building its answer. This lesson opens up the second one. It finds the slow piece, then shows four ways to win the time back.

Here is Asha's blank second, from the course's recording of this page:

Asha's screen on the visit with the jobs one after another. At 0 ms, she taps the link: a blank screen. At 656 ms, the first byte arrives: still blank. At 896 ms, the page appears: the shop's name, the mint tea photo and the teas. Below, where the time went: the trip there, then the server's jobs, db 251.4 ms, recs 151 ms and render 42.1 ms, 444.5 ms together, then the trip back. The trip took 211.5 ms in all. Then 240 ms more until the first paint.0 msblankAsha tapsthe link656 msblankthe first bytearrives896 msThe mint tea page: the shop's name, the mint tea photo, the teas and the recommendationsthe pageappearstherethe trip, there and back: 211.5 msbackthe server: 444.5 msdb251.4 msrecs151 msrender42.1 msuntil the first paint240 ms
Asha's screen at three moments, each over its time. The last screen is a real frame of the page, filmed in a separate load. Shaded: the server's three jobs, 444.5 ms together. The trip there and back took the other 211.5 ms of the wait for the first byte.

The screen stayed blank until the first paint. Most of the wait for the first byte went on the shaded bars: the server's three jobs. The next part explains them.

A server with three jobs

The course built the shop's mint tea page, and a server for it. For every visit, the server does three jobs before it can answer:

  • The query. It asks the database, the shop's store of products, for the teas on the page. This takes about 250 ms. Its short name is db.
  • The recommendations call. It asks another program, a recommendations service, which teas to suggest under "You might also like". This takes about 150 ms. Its short name is recs.
  • The template. It fills both answers into the page's HTML. This takes about 40 ms. Its short name is render.

Timers stand in for the real work, so each job takes about the same time on every visit. A round trip is the time a message takes to reach the server, plus the time the answer takes to come back. As in Time to first byte, a delay proxy, a small go-between program, sat between Chrome and the server. It held every message for half of a 100 ms round trip, so each trip there and back cost 100 ms, like a real network.

Chrome loaded each version of the page 3 times, each time on a new connection, the way a first visit does. The lesson quotes the middle one of the 3, the median. Here is the first version, where the server does its jobs one after another:

One visit, two momentsPop out in the code editor (opens in a new tab)JavaScript
// One visit to the mint tea page, the middle of 3. Times in ms from the start.const firstByte = 656;const firstPaint = 896; console.log(`The first byte of the page arrived at ${firstByte} ms.`);console.log(`The page first painted at ${firstPaint} ms.`);
  • Line 1 is a comment. Every time is in milliseconds (ms), counted from the moment the visit started.
  • Lines 2 and 3 hold two times Chrome measured. The first byte is TTFB. The first paint is the first moment any text or picture appeared on the screen. Its full name is First Contentful Paint, or FCP.
  • Line 5 prints The first byte of the page arrived at 656 ms.
  • Line 6 prints The page first painted at 896 ms.

So Asha's screen stayed blank for 896 ms, and 656 of them went on waiting for the first byte. The browser can't draw a page it hasn't received. web.dev puts it plainly: high TTFB values "add time to the metrics that follow it."

Where did the 656 ms go? The picture at the start of the lesson showed it, part by part. The next section shows how the course found it.

The server's three jobs took 444.5 ms: about two-thirds of the wait. The trip took the other 211.5 ms, which is about two round trips. Time to first byte showed what those two trips are for. So most of the wait for the first byte was the server, and that's the part the shop controls.

Real-life analogyWaiting at a café counter

You order a mint tea at a café counter. A waiter carries your order to the kitchen. There, a cook fetches the milk, asks the baker which cake goes with mint, and pours the tea. Then the waiter brings the cup back. From the counter, you can't see any of it. You only know the wait felt long.

In real life: Carrying the order to the kitchen, and the cup back
In a page load: The trip: about two round trips
In real life: The cook fetching milk, asking the baker, pouring
In a page load: The server's jobs: db, recs and render
In real life: The cup reaching the counter
In a page load: The first byte arriving: TTFB
In real life: You can't see into the kitchen
In a page load: The browser only sees how long the whole wait was
The main idea

The wait for the first byte holds the server's jobs, but the browser can't see inside them. To make the wait shorter, first name and time each job. Then shorten the slow one, skip it, or send part of the page before it ends.

The rest of the lesson does that in four steps:

  1. Measure each job with a header called Server-Timing, so the browser can see into the kitchen.
  2. Run together the jobs that don't need each other.
  3. Cache answers, so a job can be skipped next time.
  4. Flush early: send the top of the page before the jobs are done.

The course recorded a version of the page for each step. Here are all five. Start with One call after another, then try the others. Each one shows TTFB and FCP in its title. Below, it lists the jobs Chrome was told about, and when the browser requested the hero, the big photo near the top of the page.

Playground: inside the server's time
StepTime
  1. Server-Timing: db251.4 ms
  2. Server-Timing: recs151 ms
  3. Server-Timing: render42.1 ms
  4. Hero requested661.4 ms
  5. HTML done656.3 ms
Try it yourself
One call after another: TTFB 656 ms, FCP 896 ms

Each number is the middle of 3 visits, each on a new connection, over a 100 ms round trip. The Server-Timing rows are the entries Chrome read in the middle visit. The last two rows say when the browser requested the hero and when the last byte of the HTML arrived.

Recorded in Chrome 156 on Apple M1 Pro, over HTTPS, through a delay proxy that holds every message for half a round trip.

Exposing back-end phases with Server-Timing

The browser times its own side of a visit very well. As The navigation timeline showed, it writes down when the request left and when the first byte came back. But the time in between is a closed box. The Server Timing spec says the browser "has no insight into how or why certain stages of the request-response cycle have taken as much time as they have".

The server knows, though. The back end is the part of a site that runs on the server: here, the query, the call and the template. The back end can time each of its jobs and send the times along with the page.

Real-life analogyThe kitchen's order slip

Some cafés clip a slip to every order. Each cook writes their station and how long it took: "Milk, 4 minutes. Cake, 2 minutes. Pouring, 1 minute." The slip comes out with the cup. Now the person at the counter can see which station was slow, without walking into the kitchen.

In real life: The slip clipped to the order
In the server: The Server-Timing header on the response
In real life: One line per station, with its minutes
In the server: One entry per job, with dur in milliseconds
In real life: Reading the slip at the counter
In the server: Reading serverTiming in the browser

What the server sends

Every response starts with headers: short lines of information about the answer, such as what type of file it is. The page's HTML comes after them. Server-Timing is a header for times. This is the one the course's server sent with the middle visit:

The order slip, as a headerHTTP
Server-Timing: db;dur=251.4, recs;dur=151.0, render;dur=42.1
  • Line 1 holds three entries, with commas between them.
  • Each entry starts with a name the server chooses, like db.
  • After a semicolon, dur=251.4 gives the job's duration. The spec recommends milliseconds.

So the query took 251.4 ms, the recommendations call 151 ms, and the template 42.1 ms. An entry can also carry a description, written desc= and some text. Both the duration and the description are optional, so an entry can be just a name.

To fill in a duration, the server notes the time before a job and subtracts it after. In Node.js, which runs JavaScript on servers, it looks like this:

Timing one job on the serverJavaScript
const start = performance.now();const products = await queryProducts();const dbTime = performance.now() - start; response.setHeader("server-timing", `db;dur=${dbTime.toFixed(1)}`);
  • Line 1 notes the time before the job. In Node.js, performance.now() gives the time in milliseconds.
  • Line 2 runs the query and waits for its answer.
  • Line 3 subtracts, so dbTime holds how long the query took.
  • Line 5 adds the header. toFixed(1) keeps one digit after the point, like db;dur=251.4.

Reading it in the browser

The browser keeps the entries on the page's navigation entry, the object that holds the visit's times. Its serverTiming property is a list with one item per entry. Each item has a name, a duration and a description.

Reading the slip on the pageJavaScript
const [visit] = performance.getEntriesByType("navigation"); for (const { name, duration } of visit.serverTiming) {  console.log(`${name}: ${duration} ms`);}
  • Line 1 gets the navigation entry. There is one per page.
  • Line 3 goes through its serverTiming list, taking each item's name and duration.
  • Line 4 prints one line per job.

On the shop's page, it printed db: 251.4 ms, recs: 151 ms and render: 42.1 ms. The header said 151.0, and the browser read it as the number 151.

Now add up the entries and subtract them from TTFB. The closed box opens:

The server's part, and the trip's partPop out in the code editor (opens in a new tab)JavaScript
// Chrome's serverTiming list from the middle visit, and its TTFB, in ms.const serverTiming = [  { name: "db", duration: 251.4 },  { name: "recs", duration: 151 },  { name: "render", duration: 42.1 },];const ttfb = 656; const server = serverTiming.reduce((sum, entry) => sum + entry.duration, 0);console.log(`The server worked for ${server.toFixed(1)} ms.`);console.log(`The trip took the other ${(ttfb - server).toFixed(1)} ms.`);
  • Lines 2 to 6 hold the list Chrome read. Line 7 holds the visit's TTFB.
  • Line 9 adds up the three durations. reduce starts at 0 and adds one entry at a time.
  • Line 10 prints The server worked for 444.5 ms.
  • Line 11 prints The trip took the other 211.5 ms.

That's the split the picture in the overview showed. It also says where to look first: the query alone took more than half of the server's time. Without the entries, you'd only know the server was slow. With them, you know which job to fix.

In DevTools, and from real visitors

You don't need any code to see the entries. Chrome's DevTools shows them with each request's other times in the Network panel, and in the Performance panel's network track.

They reach field data too: the numbers measured on real visitors' devices. Every visit's navigation entry carries the list, so the page can send it home with its other numbers, as Sending performance data showed. The spec names this use: analytics tools can "collect, process, beacon, and aggregate these metrics". Then you can see which job is slow, on which pages, for real people.

Two cautions before you send them:

  • By default, only pages on the same origin can read the entries. An origin is the scheme, host and port together, like https://completefrontend.com. One more response header lets other origins read them. Exercise 5 asks which.
  • The entries can tell strangers a lot about how your site works inside. MDN's advice: "Decide which metrics to send, when to send them, and who should see them".

Slow queries and slow services

Now you know the query took 251.4 ms and the recommendations call 151 ms. Here is how the server ran them:

One job after anotherJavaScript
const products = await queryProducts();const recommendations = await fetchRecommendations();const body = await render(products, recommendations);
  • await pauses the function until a job is done.
  • Line 1 asks the database for the teas, and waits about 250 ms.
  • Line 2 only then asks the recommendations service, and waits about 150 ms.
  • Line 3 fills both answers into the template.

Ask one question about any two jobs: does the second one need the first one's answer? The recommendations service doesn't need the list of teas. The query doesn't need the recommendations. Yet line 2 waits for line 1 to finish. That wait is wasted.

Real-life analogyTwo errands: one waiter, or two

A mint tea needs milk from the store room, a long walk, and fresh mint from the garden, a shorter one. One waiter can do both: store room and back, then garden and back. The tea waits for both walks added up. Or two waiters can set off at the same moment. Then the tea waits only for the longer walk.

In real life: The long walk to the store room
In the server: The query, about 250 ms
In real life: The shorter walk to the garden
In the server: The recommendations call, about 150 ms
In real life: One waiter doing both walks in a row
In the server: await on each job, one after another
In real life: Two waiters setting off at once
In the server: Promise.all with both jobs
In real life: Pouring the tea
In the server: The template, which needs both answers

In JavaScript, the two waiters are Promise.all. You start both jobs first, then wait for the pair:

Both jobs at onceJavaScript
const [products, recommendations] = await Promise.all([queryProducts(), fetchRecommendations()]);const body = await render(products, recommendations);
  • Line 1 calls queryProducts() and fetchRecommendations() straight away, so both jobs start at once.
  • Promise.all waits until every job in its list is done.
  • Its answers come back in the same order as the list, whatever order the jobs finished in. So products still gets the teas.
  • Line 2 hasn't changed. The template needs both answers, so it still waits for both.

You can watch two jobs overlap. This example makes two pretend jobs with timers, one of 250 ms and one of 150 ms:

Which job finishes first?Pop out in the code editor (opens in a new tab)JavaScript
const job = (name, ms) =>  new Promise((done) => {    setTimeout(() => {      console.log(`Finished: ${name}`);      done(name);    }, ms);  }); Promise.all([job("the query", 250), job("the recommendations", 150)])  .then((answers) => console.log(`Both answers, in list order: ${answers.join(", ")}`));
  • Lines 1 to 7 make a pretend job. It waits ms milliseconds, prints that it finished, and gives back its name.
  • Line 9 starts both jobs and hands them to Promise.all.
  • Line 10 runs once both are done.
  • It prints Finished: the recommendations, then Finished: the query, then Both answers, in list order: the query, the recommendations.

The shorter job finished first. That can only happen if both ran at the same time. Press Edit & run, change 150 to 300, and run it again. Which one finishes first now?

Here is what the real server gained by running its two calls together:

What running them together savedPop out in the code editor (opens in a new tab)JavaScript
// The middle TTFB of 3 visits each way, in ms.const oneAfterAnother = 656;const inParallel = 499.7; console.log(`In parallel, the first byte came ${(oneAfterAnother - inParallel).toFixed(1)} ms sooner.`);
  • Lines 2 and 3 hold the median TTFB of each version.
  • Line 5 prints In parallel, the first byte came 156.3 ms sooner.
The back end's steps, from Server-Timing in the median run. One after another: db 251.4 ms, then recs 151 ms, then render 42.1 ms, done at 444.5 ms. In parallel: db 251.4 ms and recs 151.1 ms together, then render 42.1 ms, done at 293.5 ms.One after anotherdb251.4 msrecs151 msrender42.1 msIn paralleldb251.4 msrecs151.1 msrender42.1 ms
Each bar is one job, from Server-Timing. In parallel, the recommendations call runs during the query, so the jobs take 293.5 ms instead of 444.5 ms. Shaded: the template, which waits for both answers.

156.3 ms is about the length of the recommendations call. Running beside the query, the call now happens during the query's time. The slowest job sets the time: the server waits for the query, then the template.

That makes the query the job to work on next. A database can often find rows faster with an index, a lookup list it keeps for a column. PostgreSQL's manual calls indexes "a common way to enhance database performance". Or the query's answer can be kept for next time, which is the next section.

Entries that overlap don't add up

Chrome got the same three entries from the parallel version. But a Server-Timing entry has no start time. The spec leaves it out on purpose: the server's clock and the browser's can't be lined up.

So the browser can't tell whether two jobs ran one after another or at the same time. Add up entries that overlapped, and you count the overlap twice. Exercise 1 shows what that does. If you want the server's whole time, time the whole request too, from its first line to its last, and send that as one more entry.

Application and page caches

Running jobs together hides the shorter one. The next step skips a job completely, by keeping its answer from an earlier visit.

Real-life analogyBaking to order, or baking in the morning

One café bakes every loaf only when a customer orders it. Each customer waits for the oven. Another café bakes its popular loaves early in the morning. When someone orders one, it's already on the shelf, and they only wait to be served.

In real life: Baking a loaf when it's ordered
In the server: Running every job on every visit
In real life: The shelf of loaves baked in the morning
In the server: A cache: answers kept from before
In real life: A loaf already on the shelf
In the server: A cache hit: the job is skipped
In real life: No loaf left, so the baker bakes one
In the server: A cache miss: the job runs, and its answer is kept

A cache is that shelf: a store of answers kept to use again. When the answer is there, it's a cache hit, and the job is skipped. When it isn't, it's a cache miss. The job runs, and its answer goes on the shelf for next time.

A miss, then a hitPop out in the code editor (opens in a new tab)JavaScript
const cache = new Map(); function getRecommendations(tea) {  if (cache.has(tea)) return cache.get(tea) + " (from the cache)";  const answer = "Green tea, Chamomile"; // the slow call to the service  cache.set(tea, answer);  return answer + " (asked the service)";} console.log(getRecommendations("mint"));console.log(getRecommendations("mint"));
  • Line 1 makes the cache, an empty Map: a store of values, each kept under a key.
  • Line 4 looks for the tea's answer. If it's there, that's a hit, and the function returns it at once.
  • Line 5 only runs on a miss. A real server would call the recommendations service here.
  • Line 6 keeps the answer under the key "mint".
  • Line 10 is the first visit, a miss. It prints Green tea, Chamomile (asked the service).
  • Line 11 is the second visit, a hit. It prints Green tea, Chamomile (from the cache).

A server can keep two sizes of answer:

  • An application cache keeps one job's answer, such as the recommendations for mint tea. The other jobs still run.
  • A page cache keeps the whole finished HTML. On a hit, the server sends it without running any job.

The course built one of each. For each, it emptied the cache and loaded the page once, a miss. Then it loaded the page again, a hit. It did that 3 times. Both versions ran the query and the call together, as in the parallel version.

Caching the recommendations

The recommendations, cachedPop out in the code editor (opens in a new tab)JavaScript
// The middle TTFB of 3 visits, in ms: the recommendations cache empty, then full.const empty = 500.1;const full = 499.9; console.log(`Caching the recommendations saved ${(empty - full).toFixed(1)} ms.`);
  • Lines 2 and 3 hold the median TTFB with the cache empty, then full.
  • Line 5 prints Caching the recommendations saved 0.2 ms.

0.2 ms is nothing. Yet the cache did its job: on a hit, Server-Timing showed the recommendations step took almost no time. So why wasn't the page faster? Look back at the parallel version. The call ran during the query, and the query took longer. Making the shorter of two side-by-side jobs even shorter doesn't move the moment both are done.

The jobs the server has to wait for, one after another, are its slow path. Here, the slow path is the query, then the template. A cache only helps when it takes time off the slow path. So before you cache something, check that Server-Timing puts it there.

Caching the whole page

The whole page, cachedPop out in the code editor (opens in a new tab)JavaScript
// The middle TTFB of 3 visits, in ms: the page cache empty, then full.const empty = 500;const full = 209.2; console.log(`Caching the whole page saved ${(empty - full).toFixed(1)} ms.`);console.log(`What was left: ${full} ms.`);
  • Lines 2 and 3 hold the median TTFB with the page cache empty, then full.
  • Line 5 prints Caching the whole page saved 290.8 ms.
  • Line 6 prints What was left: 209.2 ms.
Median TTFB of 3 cold runs: Recommendations, miss 500.1 ms, Recommendations, hit 499.9 ms, Whole page, miss 500 ms, Whole page, hit 209.2 ms.Recommendations, miss500.1 msRecommendations, hit499.9 msWhole page, miss500 msWhole page, hit209.2 ms
Shaded: the cache was full. Caching the recommendations changed nothing. Caching the whole page left only the trip.

On a hit, the server ran no job at all. The 209.2 ms left is about two round trips: the trip, and nothing else. This is the shelf of loaves baked in the morning.

There's a catch. Only visitors who should see exactly the same page can share it. A page with a shopper's name or cart can't be handed to the next visitor. web.dev, writing about caching pages on a CDN, says it "is not appropriate for personalized content".

Some pages can be baked even earlier. Static rendering builds the HTML when the site is built, before anyone visits. In web.dev's words, it "generates HTML files during build time". It suits pages "that don't require markup to be personalized to a specific user."

The two caches are easy to mix up. Read each row as a question, and compare the two answers:

An application cache and a page cache
The questionAn application cacheA page cache
What does it keep?One job's answer, like the recommendations for mint tea.The whole finished HTML of a page.
What does a hit skip?That one job. The other jobs still run.Every job: the query, the call and the template.
Who can share it?Every visit that needs the same answer.Only visitors who should see exactly the same page.
What did it save here?0.2 ms, because the call wasn't on the slow path.290.8 ms. Only the trip was left.

The trip is still there

Even a page cache hit waited 209.2 ms. No cache on the server can shorten the trip to the server. Only a shorter distance can.

That's what a CDN, a content delivery network, is for. web.dev: CDNs "cache resources on servers that are physically closer to your users. These servers are called edge servers." An edge server is like a branch near you instead of the head office across the country. web.dev also warns that even with a fast back end, "users located far from your origin server may still experience high TTFB in the field."

One last caution. Caches can make a slow page look fast in your tests. If the page you test is already cached, you never see the slow jobs. web.dev: "it can mask a slow backend". To see the real server time, it suggests "testing less common pages or using different URL parameters to get uncached content".

Flushing the head of the document early

Back to the parallel version. Its first byte came at 499.7 ms. Until then, Asha's browser had nothing to do. Yet part of the page never needed the jobs at all.

That part is the head, the <head> at the top of the HTML. It holds the page's title and links to the files the page needs. On the shop's page, it asks for three files:

  • the stylesheet, styles.css, which says how the page looks;
  • the hero, through a preload: a line that asks the browser to start fetching a file early, before it reaches the <img> that shows it;
  • the script that measured each visit for the course.

None of that needs the database. The server could send it straight away.

Real-life analogyThe starter comes first

In Time to first byte, the restaurant brought the bread out first, and the stopwatch stopped early. Now look at the same trick from your seat. A good kitchen doesn't hold back the starter until the main course is cooked. The bread and the soup go to the table at once, and you eat while the main course cooks. The main course isn't any faster. But you started eating much sooner.

In real life: The starter, ready at once
In a page load: The head: the title, the stylesheet and the hero's preload
In real life: The main course, still cooking
In a page load: The rest of the page, waiting for the jobs
In real life: Eating the starter while you wait
In a page load: The browser fetching the stylesheet and the hero
In real life: The main course, as late as before
In a page load: The HTML still ends at the same time

Sending part of a response before the rest is ready is called flushing. The HTML then arrives as a stream, in pieces. web.dev: "Browsers are optimized to process markup efficiently when it's streamed, meaning that markup is handled in chunks as it arrives from the server."

Here is the course's flushing server, without its two timing lines:

The server, sending the head firstJavaScript
async function flushHeadEarly(response, segment) {  response.writeHead(200, { "content-type": "text/html" });  response.write(pageHead(segment));   const [products, recommendations] = await Promise.all([queryProducts(), fetchRecommendations()]);  const body = await render(products, recommendations);  response.end(body);}
  • Line 2 sets the status, 200 for OK, and says the answer is HTML.
  • Line 3 writes the head. Node.js's docs say the first response.write() sends "the buffered header information and the first chunk of the body to the client". So the headers and the head leave now, before any job starts.
  • Line 5 runs the query and the recommendations call together, as before.
  • Line 6 fills the template.
  • Line 7 sends the rest of the page and ends the response.

segment on line 1 is a small label the course put in each page, so it could tell its visits apart. You don't have to write this by hand, either. web.dev notes that newer versions of React have "server methods for streaming markup".

What the browser did meanwhile

The course loaded the page sent whole, as in the parallel version, and with the head first. First, when things started:

When the first byte came, and when the hero startedPop out in the code editor (opens in a new tab)JavaScript
// The middle of 3 visits each way, in ms from the start of the visit.const sentWhole = { firstByte: 499.7, hero: 504.3 };const headFirst = { firstByte: 210.7, hero: 214 }; console.log(`Sent whole: first byte at ${sentWhole.firstByte} ms, hero requested at ${sentWhole.hero} ms`);console.log(`Head first: first byte at ${headFirst.firstByte} ms, hero requested at ${headFirst.hero} ms`);
  • Lines 2 and 3 hold the median of each version: when the first byte came, and when the browser requested the hero.
  • Line 5 prints Sent whole: first byte at 499.7 ms, hero requested at 504.3 ms.
  • Line 6 prints Head first: first byte at 210.7 ms, hero requested at 214 ms.

With the head first, the first byte came after about two round trips, long before the jobs were done. The browser requested the hero 3.3 ms later, while the server was still working. Then, when things ended:

When the HTML ended, and when the page paintedPop out in the code editor (opens in a new tab)JavaScript
// The middle of 3 visits each way, in ms from the start of the visit.const sentWhole = { htmlDone: 500.5, firstPaint: 740 };const headFirst = { htmlDone: 502.6, firstPaint: 524 }; console.log(`Sent whole: HTML done at ${sentWhole.htmlDone} ms, first paint at ${sentWhole.firstPaint} ms`);console.log(`Head first: HTML done at ${headFirst.htmlDone} ms, first paint at ${headFirst.firstPaint} ms`);
  • Lines 2 and 3 hold when the last byte of the HTML arrived, and the first paint.
  • Line 5 prints Sent whole: HTML done at 500.5 ms, first paint at 740 ms.
  • Line 6 prints Head first: HTML done at 502.6 ms, first paint at 524 ms.
Medians of 3 cold runs. Sent whole: first byte 499.7 ms, hero requested 504.3 ms, html done 500.5 ms, first paint 740 ms. Head first: first byte 210.7 ms, hero requested 214 ms, html done 502.6 ms, first paint 524 ms.Sent wholeFirst byte499.7 msHero requested504.3 msHTML done500.5 msFirst paint740 msHead firstFirst byte210.7 msHero requested214 msHTML done502.6 msFirst paint524 ms
Shaded: the first paint. With the head sent first, the browser requested the hero while the server worked, and painted 216 ms sooner. The HTML ended at the same time.

The HTML ended at about the same moment either way, because the server did the same jobs in the same time. But the first paint came 216 ms sooner. The stylesheet and the hero were already on their way when the rest of the page arrived. That's the starter: the main course was just as late, but the table got going sooner.

The price: Server-Timing has already left

Flushing has one cost. Headers travel before the body. When the server flushes the head at the start, its headers leave before any job has run, so they can't hold the jobs' times.

HTTP has an answer on paper: a trailer, a header sent after the body. Node.js's docs call it "a header but at the end of the message". Here is the course's real function, with the two lines that send the times as a trailer:

The same server, sending its times as a trailerJavaScript
async function flushHeadEarly(response, segment) {  response.writeHead(200, { "content-type": "text/html", trailer: "Server-Timing" });  response.write(pageHead(segment));   const [products, recommendations] = await Promise.all([queryProducts(), fetchRecommendations()]);  const body = await render(products, recommendations);  response.addTrailers({ "server-timing": timings() });  response.end(body);}
  • Line 2 adds trailer: "Server-Timing". It announces that a Server-Timing trailer will follow the body, which Node.js's docs say HTTP requires.
  • Line 7 adds the trailer with the three times, just before line 8 ends the response.

The server measured all three jobs on every flushed visit. But Chrome's serverTiming list stayed empty every time: Chrome doesn't read Server-Timing from a trailer. MDN: "Only the browser's DevTools can use the Server-Timing header as an HTTP trailer", and only some browsers do, as the box below shows.

So a page that flushes early has two ways to keep its times. It can put in the header what the server knows before it flushes, like whether the page came from a cache. And it can keep the jobs' times on the server. web.dev suggests Application Performance Monitoring tools when Server-Timing isn't possible.

Which fix fits?

Each card below is a problem a shop might find. First ask: do you know which job is slow? If you do, ask whether that job can run beside another one, be skipped by a cache, or be waited out while the browser works on the head. Sort each card by the first fix to try.

Which fix fits?
  • TTFB is high, and nobody knows which job is slow
  • Only some pages have a slow TTFB for real visitors
  • The product query and the stock check don't need each other's answers
  • Two API calls run one after the other, and the second doesn't use the first one's answer
  • Every visitor who isn't logged in gets the same product page
  • The server reads prices that change once a day, and Server-Timing shows that read on the slow path
  • The <head> waits for a query it doesn't use
  • A query that's already as fast as it can be, for a page made for each shopper
Try it yourself
0 of 8 correct

Sort each problem by the fix to try first.

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

Common mix-ups

  • "Caching anything makes the page faster." No. Caching the recommendations saved 0.2 ms, because the call wasn't on the slow path. Cache the jobs the server has to wait for.
  • "Server-Timing entries add up to the server's time." No. Entries have no start times. When jobs run together, their entries overlap, and the sum comes out too big.
  • "Running jobs in parallel makes each job faster." No. The query took about as long as before. The server just stopped waiting for the recommendations call on its own.
  • "Flushing early makes the server finish sooner." No. The HTML ended at about the same moment either way. What moved was the first paint, because the browser started on the head sooner.
  • "One page cache can serve every visitor." No. Only visitors who should see exactly the same page can share it. A page with a shopper's name or cart can't go to the next one.
  • "A slow TTFB always means a slow server." No. A page cache hit, with no jobs at all, still took 209.2 ms: about two round trips. Subtract the server's entries before you blame it.

Practice exercises

Answer each one first, then use the hints if you need them.

Exercise 1 · Warm-upAdding up entries that overlap

This is the same sum with the parallel version's entries and TTFB. What does the last line print? Is it true?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Chrome's serverTiming list from the middle visit with the calls in parallel, and its TTFB, in ms.
const serverTiming = [
  { name: "recs", duration: 151.1 },
  { name: "db", duration: 251.4 },
  { name: "render", duration: 42.1 },
];
const ttfb = 499.7;

const server = serverTiming.reduce((sum, entry) => sum + entry.duration, 0);
console.log(`The server worked for ${server.toFixed(1)} ms.`);
console.log(`The trip took the other ${(ttfb - server).toFixed(1)} ms.`);

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

    Exercise 2 · Warm-upThe caches and the first paint

    This is the caches' comparison again, with FCP instead of TTFB. What does the second line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // The middle FCP of 3 visits, in ms: each cache empty, then full.
    const fcp = {
      "recommendations cached": { empty: 744, full: 736 },
      "whole page cached": { empty: 736, full: 448 },
    };
    
    for (const [cache, { empty, full }] of Object.entries(fcp)) {
      console.log(`${cache}: ${empty} to ${full} ms, ${(empty - full).toFixed(1)} ms sooner`);
    }

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

      Exercise 3 · PracticeA cache hit in Server-Timing

      With the recommendations cached and the cache full, how long did the recommendations step take, by its Server-Timing row?

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

        Exercise 4 · PracticeHow early the hero started

        With the whole page cached and the cache full, when did the browser request the hero?

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

          Exercise 5 · ChallengeEntries for other origins

          A page on another origin wants to read your Server-Timing entries. Which response header lets it?

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

            Exercise 6 · ChallengeA time known only at the end

            The Server Timing spec's examples send one metric as a trailer, at the end of the response. What is that metric's name?

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

              Check your understanding

              For each question, ask two things. Which job is the server waiting for? And what could the browser be doing while it waits?

              Faster server responses quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the last line of this Server-Timing sum print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Chrome's serverTiming list from the middle visit, and its TTFB, in ms.
                const serverTiming = [
                  { name: "db", duration: 251.4 },
                  { name: "recs", duration: 151 },
                  { name: "render", duration: 42.1 },
                ];
                const ttfb = 656;
                
                const server = serverTiming.reduce((sum, entry) => sum + entry.duration, 0);
                console.log(`The server worked for ${server.toFixed(1)} ms.`);
                console.log(`The trip took the other ${(ttfb - server).toFixed(1)} ms.`);

                Choose an answer to see the explanation.

              2. Question 2 of 7In this version, the recommendations call takes 300 ms. What does the first line print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const job = (name, ms) =>
                  new Promise((done) => {
                    setTimeout(() => {
                      console.log(`Finished: ${name}`);
                      done(name);
                    }, ms);
                  });
                
                Promise.all([job("the query", 250), job("the recommendations", 300)])
                  .then((answers) => console.log(`Both answers, in list order: ${answers.join(", ")}`));

                Choose an answer to see the explanation.

              3. Question 3 of 7Two jobs that don't need each other take about 250 ms and 150 ms. Run together, about how long does the server wait for them?

                Choose an answer to see the explanation.

              4. Question 4 of 7Caching the recommendations didn't move TTFB. Why?

                Choose an answer to see the explanation.

              5. Question 5 of 7After a page cache hit, the first byte still took about two round trips. What would shorten that wait?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does flushing the head early change?

                Choose an answer to see the explanation.

              7. Question 7 of 7A page flushes its head early. Can Chrome still read its jobs' times from Server-Timing?

                Choose an answer to see the explanation.

              Key takeaways

              • The wait for the first byte holds the trip and the server's jobs. The browser only sees the whole wait.
              • Send a Server-Timing entry for each job. Read it in DevTools, on the navigation entry, and in field data.
              • Run jobs that don't need each other together. Then the slowest one sets the time.
              • Cache what's on the slow path. A page cache saves the most, for visitors who see the same page.
              • After a cache hit, what's left is the trip. A server closer to the visitor, like a CDN's edge, shortens it.
              • Flush the head early, so the browser fetches the stylesheet and the hero while the server works.
              • A flushed page can't send its jobs' times in the header, and Chrome ignores Server-Timing trailers.

              Remember the one-liner.
              Measure each job, cut the slow path, and send what you can early.

              Sources

              The facts on this page come from these docs:

              Coming next: Redirects, also in Fast servers, caches, and networks. A redirect sends the browser to another address before it gets the page, and each one costs at least one more trip. You'll find redirect chains and remove them.

              CompleteFrontend Clear concepts. Working examples.