Brazilian born, previously Spotify and Klarna, Vinicius Dallacqua works with different teams driving development and tooling at Volvo Cars in Sweden. Working on the web direct sales platform and helping drive the performance culture by building tooling and sharing knowledge.
Some of the times the performance opportunities are easily found and are one of the usual suspects: image size; uncompressed assets; bundle size; preconnect; prefetch; etc. But sometimes the root cause is not as easily found, and those might be on the other side of the server-client boundary. But how can you address and identify those opportunities and what does the platform provide you in order to break down your network graph from the other side of the wire?
Why is my TTFB so slow?
When breaking down slow response times you may find yourself staring at a black box. Seeing the TTFB as one of the main culprits for your loading time problems, or even in some cases slow responsiveness and interactions performing data fetching after load.
Some of the teams I work with faced that very problem. When our backend responses were taking too much time to respond. Whilst the application in question uses a BFF (Backend For Frontend), most of the interactions on that BFF are through other internal services, Graphs and APIs that each could be potential candidates for that problem. Alongside cache misses and other external factors. And those being also separate teams and dependencies, how can you ensure you are reaching out to the right team about the right problems?
Using the platform to help understand TTFB
The platform has two great tools to help us breakdown and understand what happens over the network boundary and identify problems that might be hindering your TTFB.
Performance
interface and high resolution timestamp
The Performance
interface and the Performance.now
method are key pieces to accurately measure function execution time for both browser and Node.js. Which is useful to help us time our operations with precision. MDN has a great section on why high resolution time is important and the difference between it and Date.now
. But in summary, the Performance.now
will return to us a high resolution timestamp that we can use to time the start and end of our function calls and time our total execution time for our server operations.
Server-Timing
HTTP header
The Server-Timing
header is part of the Performance API and it allows us to communicate our server metrics to the browser developer tooling when investigating our network requests. It allows us to send one or more metrics to represent our server response part of the network request, or TTFB from the web-vitals perspective.
Basic Format
The Server-Timing
header is composed of a comma-separated list of metrics. Each metric in the list can have up to three components, separated by a semicolon:
- Metric Name: A token representing the name of the metric.
- Duration: Represented by
dur=<value>
. A numeric value representing the time taken, typically in milliseconds. - Description (optional): Represented by
desc=""
. A human-readable description of the metric.
Server-Timing: name;dur=duration;desc="description"
Here’s an example of a Server-Timing
header with multiple metrics:
Server-Timing: db;dur=0.53, cache;dur=0.15;desc="Cache readout", fs;dur=0.800;desc="File System read"
Besides the network tab under developer tools, your Server-Timing
metrics can also be accessed via the PerformanceResourceTiming
interface as PerformanceServerTiming
entries.
To access those entries you can use a PerformanceObserver
like shown bellow:
const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { entry.serverTiming.forEach((serverEntry) => { console.log( `${serverEntry.name} (${serverEntry.description}) duration: ${serverEntry.duration}` ); // Logs "cache (Cache Read) duration: 23.2" // Logs "db () duration: 53" // Logs "app () duration: 47.2" }); }); }); ["navigation", "resource"].forEach((type) => observer.observe({ type, buffered: true }) );
Putting the pieces together
Lets create a simple function to time how long different parts of the backend request take and store it, to later convert it down to the format accepted by the Server-Timing header.
// server.js // Time an function, fn, execution and store it under timings[] async function time( fn = Promise.resolve(() => "noop"), { name = "some_request", description, timings = [] } = {} ) { const timerStart = performance.now(); const promise = typeof fn === "function" ? fn() : fn; const result = await promise; const totalTime = performance.now() - timerStart; timings.push({ name, description, duration: totalTime }); /** * Example values stored in `timings` array: * [ * { name: "db_read", description: "find user in DB", duration: 0.123 }, * { name: "some_request", description: undefined, duration: 0.456 }, * ] */ return result; } // Simple conversion util to parse the data in the timings array into a string // matching the accepted format for the Server-Timing header function timingStringValue(timings = []) { return timings.reduce( (acc, { name, description, duration }) => `${acc.length ? `${acc},` : ""}${name};${ description ? `desc="${description}";` : "" }dur=${duration}`, "" ); } // Simple node route handler example export async function routeHandler({ request }) { const timings = []; const userId = request.param?.userId; const user = userId ? await time(() => myORM.users.query({ id: userId }), { timings, name: "db_read", description: "find user in DB", }) : null; return new Response(JSON.stringify(user), { headers: { "Server-Timing": timingStringValue(timings) }, }); }
The time
function breakdown
This asynchronous function is used to measure the time it takes to execute a given server operation (fn
). It has two parameters:
fn
: The operation function or a promise. If a function is provided, it should return a promise to be awaited.- An options object containing properties representing the different options for the
Server-Timing
header and atimings
array to store the different timings as we collect them:name
: A string indicating the type of operationdescription
: A description of the operation.timings
: An array to store timing data for the different operations and later use to send as data for theServer-Timing
header.
Now this function is but a simple example with its own shortcomings such as:
- The
timings
array is mutated in place, which makes it harder to store timings for operations on other layers of the application. - The
name
property does not parse or safeguard for the fact that theServer-Timing
header does not accept entry names with spaces.
For that reason I recommend using or building a helper such as the timing.server.ts
from the EpicStack from Kent C. Dodds. EpicStack is a Remix community stack, but the code itself for the timing.server.ts utils is universal and can be used on any node backend as a util to quickly add server timing to your responses.
Lets visualize how our code above would look like using such a util lib to abstract away the code to manage the timings.
import { makeTimings, time } from "./utils/timing.server.ts"; import { json } from "@remix-run/node"; export async function routeHandler({ request }) { const timings = makeTimings("root loader"); const userId = request.param?.userId; const user = userId ? await time(() => myORM.users.query({ id: userId }), { timings, name: "db read", description: "find user in DB", }) : null; return new Response(JSON.stringify(user), { headers: { "Server-Timing": timings.toString() }, }); }
The image below shows an example of how a request under the network tab would show us the breakdown of our TTFB under a Server Timing section when we click on it and navigate to the Timing tab
Server timings in the wild
Choose your backend flavour
Since Server-Timing
is an HTTP Header its usage is not restricted to Node.js backends. In fact you can use it with Laravel as a middleware, with Rails as a gem or your favourite backend language and framework. As long as you provide the correct header and metrics format, the browser APIs and developer tools will be able to capture them.
RUM tools
You can utilize third party RUM tools to capture and monitor your Server Timing data in the wild and get a better understanding of your TTFB metric from your users’ perspective. Tools like RUMVision and SpeedCurve provide great support out of the box for your monitoring needs.
Security and privacy considerations
It’s important to notice however that you should avoid exposing sensitive information openly from your backend over to the ServerTiming header. See the Privacy and Security Considerations section for Server timing over on MDN for more details.