Skip to content
Back to blog

Performance

Your page takes 800 ms to the first byte. Doing what, exactly?

TTFB is a single number for everything that happens before the first byte. How to split it into phases with one response header, why search and key-value stores deserve phases of their own, and how Early Hints make your TTFB look better than it is.

Kamil Adrian Czujowski

Kamil Adrian Czujowski

5 min read


Honestly: TTFB is the most thankless number on the dashboard. It sits there, says 800 ms, and that is where the information ends. Was it the database? A template doing too much? The payment provider whose API is having a bad day? The cache that did not hit? The number knows. It just does not say.

And the answer is usually one response header away.

TTFB is a sum, not a diagnosis

Time to First Byte measures the span from the click to the first byte of the response. Everything is in there: DNS, connection, TLS, the trip to the edge, the trip from the edge to the origin, and then all the work your application does. One value for half a dozen stations.

As long as it is small, that is fine. Once it grows, every optimization becomes a bet. You rewrite the database query, deploy, wait two days for enough data, look. Nothing moved. So the template. Deploy, wait, look. Still nothing. After the third round it turns out to be the search index nobody thought about.

That is not a skill problem, it is a visibility problem. You are optimizing a sum without knowing its parts.

The header is three lines in your backend

Server-Timing is a standard response header your backend uses to attach where the time went to every response. No SDK, no extra request, no instrumentation in the browser. You are already measuring somewhere in your code, you just write the values into a header:

$response->headers->set('Server-Timing', sprintf(
    'db;dur=%.1f, render;dur=%.1f, cache;dur=%.1f',
    $dbMs, $renderMs, $cacheMs
));

That is Symfony, which is also the Shopware world. Flask, Express and the rest take the same three lines, they are in the docs. The browser reads the header anyway, and our beacon picks it up from the document response on every pageview. From then on the breakdown is part of your field data, measured on real visitors instead of one test run.

Nine phases, and what sits behind them

To turn other people’s metric names into something comparable, fastmon normalizes them into nine phases:

  • Edge (edge_dur) and Origin (origin_dur): the route. Time at the edge, and time between edge and origin server.
  • Backend (backend_dur): the application as a whole. This is also where WordPress lands with its wp-total.
  • Database (db_dur) and Rendering (render_dur): the two classics, relational queries and building the page.
  • Cache (cache_dur), External calls (external_dur), Search (search_dur) and Key-value store (kv_dur): the four that get summed, because a single request tends to hit several of them.

The usual names are already known: elasticsearch, opensearch and solr land in search, redis and valkey in the key-value store, http, fetch and api in external calls. If you want it unambiguous, send the phases under their fm- names, which take precedence over everything else.

You see the result under Analytics, Server-Timing, in the response breakdown waterfall and as a column in the explorer, each as p50, p75 and p95. Percentiles, not averages, for the same reason as everywhere else: the average flatters, the p75 shows you the visitors who actually suffer.

What you read off it

The moment those three lines pay for themselves looks roughly like this: backend 60 ms, database 25 ms, rendering 30 ms, search 240 ms. The discussion about query optimization is over before it started. The database is not slow, the search index is.

Or: everything under 50 ms, external calls at 300 ms. Then your TTFB is not about your code, it is about a service somebody hung into the page build synchronously two months ago.

Or the uncomfortable one: cache phase high, cache hit rate good. Then the cache is not the problem, it is a solution that takes too long, usually because it is reached over the network instead of a local socket.

None of the three is visible in a single number. All three jump out at you in ten seconds once there are four.

Two new phases, and a step in the chart

Search and key-value stores have been phases of their own since 27 August. Before that both sat in cache_dur, which was still sort of true for Redis and had not been true for Elasticsearch in a long time. Throwing them together meant you could see that a store was slowing you down, but not which one.

That has an honest side effect you should know about before you notice it: because Redis moved out of the cache phase into the key-value store, cache_dur gets smaller from that deploy on. In the chart that is a step down. It is not an improvement, it is a reshuffle. We did not touch the values already stored, which is why the step is there and stays visible.

Early Hints: when your TTFB starts lying

And then there is the case where your TTFB improves without anything getting faster.

With 103 Early Hints your server, usually the edge in front of it, sends a provisional response carrying preload and preconnect hints while your backend is still working. The browser starts fetching files early. For your visitors that is a real gain.

For your measurement it is a trap: the first byte that arrives is the 103, not your actual response. Since Chrome 133, responseStart counts that provisional response, and because almost every tool derives its TTFB from it, your number drops the moment you switch Early Hints on. Your server takes exactly as long as before.

So the fastmon tracker additionally reads the timestamp of the final response headers and puts it next to it as ttfb_final. Two values, one clear answer:

  • Both equal: there was no provisional response, your TTFB is your TTFB.
  • ttfb_final larger: the difference is exactly the head start the edge gave your visitors. The TTFB page shows it at your chosen percentile, the response breakdown as a line of its own from the first byte to the final headers.
  • Empty: the browser does not report it, or it was a soft navigation.

Which gives you the rule of thumb people otherwise miscalculate: the edge and backend phases have to fit into ttfb_final, not into ttfb. Measure against the smaller of the two and you end up puzzled by phases that supposedly last longer than the whole response.

Note: Which metric names map to which phase, how to add your own, and the limits for values and descriptions are all in our Server-Timing docs. That is the page for your developers.

What to take away

TTFB tells you it was slow. Server-Timing tells you where. The difference between the two is three lines in your backend, and after that a discussion held with numbers instead of guesses.

Start with db, render and cache, that covers most cases. If a search index or a key-value store is involved, add those two, they have earned it. And if you run Early Hints, look at ttfb_final before celebrating a suddenly excellent TTFB.

All of it measured on real visitors, not in a lab. More on Real User Monitoring.