Web Performance Calendar

The speed geek's favorite time of year
2023 Edition
ABOUT THE AUTHOR
Photo of Vinicius Dallacqua

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:

  1. Metric Name: A token representing the name of the metric.
  2. Duration: Represented by dur=<value>. A numeric value representing the time taken, typically in milliseconds.
  3. 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 a timings array to store the different timings as we collect them:
    • name: A string indicating the type of operation
    • description: A description of the operation.
    • timings: An array to store timing data for the different operations and later use to send as data for the Server-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 the Server-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

Screenshot of a developer tools panel showing a breakdown of a network request

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.

One Response to “Measuring, monitoring and optimizing TTFB with Server timing”

  1. Nailson Landim

    Nice article! besides being quite away from FE, is interesting to see how you can perform there.

Leave a Reply

You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>
And here's a tool to convert HTML entities