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.
- 01See inside the server's timeSend a
Server-Timingentry for each job, and read it in DevTools, on the page and in field data. - 02Cut the slow pathRun jobs that don't need each other together, and cache the job the server really waits for.
- 03Send 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:
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 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.
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,recsandrender - 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 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:
- Measure each job with a header called
Server-Timing, so the browser can see into the kitchen. - Run together the jobs that don't need each other.
- Cache answers, so a job can be skipped next time.
- 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.
- Server-Timing: db251.4 ms
- Server-Timing: recs151 ms
- Server-Timing: render42.1 ms
- Hero requested661.4 ms
- HTML done656.3 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.
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.
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-Timingheader on the response - In real life: One line per station, with its minutes
- In the server: One entry per job, with
durin milliseconds - In real life: Reading the slip at the counter
- In the server: Reading
serverTimingin 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:
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.4gives 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:
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
dbTimeholds how long the query took. - Line 5 adds the header.
toFixed(1)keeps one digit after the point, likedb;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.
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
serverTiminglist, 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:
// 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.
reducestarts 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:
const products = await queryProducts();const recommendations = await fetchRecommendations();const body = await render(products, recommendations);awaitpauses 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.
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:
awaiton each job, one after another - In real life: Two waiters setting off at once
- In the server:
Promise.allwith 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:
const [products, recommendations] = await Promise.all([queryProducts(), fetchRecommendations()]);const body = await render(products, recommendations);- Line 1 calls
queryProducts()andfetchRecommendations()straight away, so both jobs start at once. Promise.allwaits 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
productsstill 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:
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
msmilliseconds, 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, thenFinished: the query, thenBoth 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:
// 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.
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.
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.
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 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 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.
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:
| The question | An application cache | A 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.
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:
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,
200for 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:
// 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:
// 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.
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:
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.
- 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
Sort each problem by the fix to try first.
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.
This is the same sum with the parallel version's entries and TTFB. What does the last line print? Is it true?
// 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.`);It prints The trip took the other 55.1 ms. That isn't true. The query and the recommendations call ran at the same time, so the sum counts their overlap twice. The server really waited for the query and then the template: 251.4 + 42.1 = 293.5 ms. That leaves 206.2 ms for the trip, about two round trips, as in every other version.
This is the caches' comparison again, with FCP instead of TTFB. What does the second line print?
// 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`);
}It prints whole page cached: 736 to 448 ms, 288.0 ms sooner. The first paint moved by almost exactly what TTFB moved, 290.8 ms: nothing can paint before the first byte. The first line's 8.0 ms is smaller than the 20 ms between the fastest and slowest of its three empty-cache visits, so it's noise.
With the recommendations cached and the cache full, how long did the recommendations step take, by its Server-Timing row?
1.5 ms, down from about 150 ms. The cache worked. TTFB still didn't move, because the query running beside it took about 250 ms.
With the whole page cached and the cache full, when did the browser request the hero?
213.2 ms. That's about when the flushed version requested it, at 214 ms. In both, the head arrived after about two round trips. The page cache sent the whole page then; flushing sent only the head.
A page on another origin wants to read your Server-Timing entries. Which response header lets it?
Timing-Allow-Origin. The server lists the origins allowed to read its times. Without it, only pages on the response's own origin can read the entries.
The Server Timing spec's examples send one metric as a trailer, at the end of the response. What is that metric's name?
total. The whole request's time is only known when the response is done, so the spec's example sends it in a trailer. Chrome would ignore it there, as the flushed visits showed.
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?
Question 1 of 7What does the last line of this Server-Timing sum print?
Read the code, then predict// 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.
Question 2 of 7In this version, the recommendations call takes 300 ms. What does the first line print?
Read the code, then predictconst 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.
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.
Question 4 of 7Caching the recommendations didn't move TTFB. Why?
Choose an answer to see the explanation.
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.
Question 6 of 7What does flushing the head early change?
Choose an answer to see the explanation.
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-Timingentry 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:
- Time to First Byte (TTFB), web.dev.
- First Contentful Paint (FCP), web.dev.
- Optimize Time to First Byte, web.dev.
- Server Timing, W3C.
- Server-Timing header, MDN.
- Performance measurement APIs: performance.now(), Node.js.
- HTTP: class http.ServerResponse, Node.js.
- Promise.all(), MDN.
- Indexes, PostgreSQL.
- rel=preload, MDN.
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.