Custom metrics

Having universal, user-centric metrics that you can measure on any website can be very helpful in understanding how your users experience the web, and in comparing your site to competitors'. However, in many cases, you need to measure more than just the universal metrics to capture the full experience for your specific site.

Custom metrics let you measure aspects of your site's experience that might only apply to your site, such as:

  • How long it takes for a single-page app (SPA) to transition from one "page" to another.
  • How long it takes for a page to display data fetched from a database for logged-in users.
  • How long it takes for a server-side-rendered (SSR) app to hydrate.
  • The cache hit rate for resources loaded by returning visitors.
  • The event latency of click or keyboard events in a game.

APIs to measure custom metrics

Web developers haven't historically had many low-level APIs to measure performance, and as a result, they've had to resort to hacks to measure whether a site performed well. For example, you can determine whether the main thread is blocked by long-running JavaScript tasks by running a requestAnimationFrame loop and calculating the delta between each frame. If the delta is significantly longer than the display's framerate, you can report that as a long task.

However, hacks like this can affect your site's performance, for example by draining the device's battery. If your performance measurement techniques cause performance issues themselves, the data you get from them won't be accurate. Therefore, we recommend using one of the following APIs for creating custom metrics.

Performance Observer API

Browser Support

  • 52
  • 79
  • 57
  • 11

Source

The Performance Observer API is the mechanism that collects and displays data from all other performance APIs discussed on this page. Understanding it is critical to getting good data.

You can use PerformanceObserver to passively subscribe to performance-related events. This allows API callbacks to fire during idle periods, which means they usually won't interfere with page performance.

When creating a PerformanceObserver, pass it a callback that runs whenever new performance entries are dispatched. Then use the observe() method to tell the observer what types of entries to listen for as follows:

// Catch errors that some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Log the entry and all associated details.
      console.log(entry.toJSON());
    }
  });

  po.observe({type: 'some-entry-type'});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}

The following sections list all the entry types you can observe. In newer browsers, you can also inspect what entry types are available using the static PerformanceObserver.supportedEntryTypes property.

Observe entries that already happened

By default, PerformanceObserver objects can only observe entries as they occur. This can cause problems if you want to lazy-load your performance analytics code so it doesn't block higher-priority resources.

To get historical entries, call observe with the buffered flag set to true. The browser then includes historical entries from its performance entry buffer the first time your PerformanceObserver callback is called.

po.observe({
  type: 'some-entry-type',
  buffered: true,
});

Legacy performance APIs to avoid

Prior to the Performance Observer API, developers could access performance entries using the following methods defined on the performance object. We don't recommend using them because they don't let you listen for new entries.

In addition, many new APIs (such as Long Tasks) aren't exposed by the performance object, only by PerformanceObserver. Therefore, unless you specifically need Internet Explorer compatibility, it's best to avoid these methods in your code and use PerformanceObserver going forward.

User Timing API

The User Timing API is a general-purpose measurement API for time-based metrics. It lets you arbitrarily mark points in time and then measure the duration between those marks later.

// Record the time immediately before running a task.
performance.mark('myTask:start');
await doMyTask();

// Record the time immediately after running a task.
performance.mark('myTask:end');

// Measure the delta between the start and end of the task
performance.measure('myTask', 'myTask:start', 'myTask:end');

Although APIs like Date.now() or performance.now() give you similar abilities, the User Timing API is better integrated with performance tooling. For example, Chrome DevTools visualizes User Timing measurements in the Performance panel, and many analytics providers automatically track any measurements you make and send the duration data to their analytics backend.

To report User Timing measurements, register a PerformanceObserver to observe entries of type measure:

// Catch errors some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Log the entry and all associated details.
      console.log(entry.toJSON());
    }
  });

  // Start listening for `measure` entries.
  po.observe({type: 'measure', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}

Long Tasks API

Browser Support

  • 58
  • 79
  • x
  • x

Source

The Long Tasks API is useful for determining when the browser's main thread is blocked for long enough to affect frame rate or input latency. The API reports any tasks that execute for longer than 50 milliseconds (ms).

Any time you need to run expensive code, or load and execute large scripts, it's useful to track whether that code blocks the main thread. In fact, many higher-level metrics are built on top of the Long Tasks API themselves (such as Time to Interactive (TTI) and Total Blocking Time (TBT)).

To determine when long tasks happen, register a PerformanceObserver to observe entries of type longtask:

// Catch errors since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Log the entry and all associated details.
      console.log(entry.toJSON());
    }
  });
  // Start listening for `longtask` entries to be dispatched.
  po.observe({type: 'longtask', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}

Element Timing API

Browser Support

  • 77
  • 79
  • x
  • x

Source

The Largest Contentful Paint (LCP) metric is useful for knowing when the largest image or text block on your page is painted to the screen, but in some cases, you want to measure the render time of a different element.

For these cases, use the Element Timing API. The LCP API is actually built on top of the Element Timing API and adds automatic reporting of the largest contentful element, but you can also report on other elements by explicitly adding the elementtiming attribute to them, and registering a PerformanceObserver to observe the element entry type.

<img elementtiming="hero-image" />
<p elementtiming="important-paragraph">This is text I care about.</p>
...
<script>
// Catch errors since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((entryList) => {
    for (const entry of entryList.getEntries()) {
      // Log the entry and all associated details.
      console.log(entry.toJSON());
    }
  });
  // Start listening for `element` entries to be dispatched.
  po.observe({type: 'element', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}
</script>

Event Timing API

The Interaction to Next Paint (INP) metric assesses overall page responsiveness by observing all click, tap, and keyboard interactions throughout the life of a page. A page's INP is most typically the interaction that took the longest to complete, from the time the user initiated the interaction, to the time the browser paints the next frame showing the visual result of the user's input.

The INP metric is made possible by the Event Timing API. This API exposes a number of timestamps that occur during the event lifecycle, including:

  • startTime: the time when the browser receives the event.
  • processingStart: the time when the browser can start processing event handlers for the event.
  • processingEnd: the time when the browser finishes executing all synchronous code initiated from event handlers for this event.
  • duration: the time (rounded to 8 ms for security reasons) between when the browser receives the event until it's able to paint the next frame after finishing executing all synchronous code initiated from the event handlers.

It's possible to use these timestamps to measure discrete parts of every interaction made with a page, or even specific interactions that may be of high value to your users.

The following code snippet shows how you can get various bits of useful data for every interaction that occurs on the page:

// Catch errors some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  const po = new PerformanceObserver((entryList) => {
    // Get the last interaction observed:
    const entries = Array.from(entryList.getEntries()).forEach((entry) => {
      // Get various bits of interaction data:
      const inputDelay = entry.processingStart - entry.startTime;
      const processingTime = entry.processingEnd - entry.processingStart;
      const duration = entry.duration;
      const eventType = entry.name;
      const target = entry.target || "(not set)"

      console.log("----- INTERACTION -----");
      console.log(`Input delay (ms): ${inputDelay}`);
      console.log(`Event handler time (ms): ${processingTime}`);
      console.log(`Total event duration (ms): ${duration}`);
      console.log(`Event type: ${eventType}`);
      console.log(target);
    });
  });

  // A durationThreshold of 16ms is necessary to surface more
  // interactions, since the default is 104ms. The minimum
  // durationThreshold is 16ms.
  po.observe({type: 'event', buffered: true, durationThreshold: 16});
} catch (error) {
  // Do nothing if the browser doesn't support this API.
}

Resource Timing API

The Resource Timing API gives developers detailed insight into how resources for a particular page were loaded. Despite the name of the API, the information it provides isn't just limited to timing data (though there's plenty of that). Other data you can access includes:

  • initiatorType: how the resource was fetched, such as from a <script> or <link> tag, or from fetch().
  • nextHopProtocol: the protocol used to fetch the resource, such as h2 or quic.
  • encodedBodySize and decodedBodySize]: the size of the resource in its encoded or decoded form (respectively).
  • transferSize: the size of the resource that was actually transferred over the network. When resources are fulfilled using the cache, this value can be much smaller than the encodedBodySize, and in some cases it can be zero, if no cache revalidation is required.

You can use the transferSize property of resource timing entries to measure a cache hit rate metric or a total cached resource size metric, which can be useful in understanding how your resource caching strategy affects performance for repeat visitors.

The following example logs all resources requested by the page and indicates whether or not each resource was fulfilled using the cache:

// Catch errors some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // If transferSize is 0, the resource was fulfilled via the cache.
      console.log(entry.name, entry.transferSize === 0);
    }
  });
  // Start listening for `resource` entries to be dispatched.
  po.observe({type: 'resource', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}

Browser Support

  • 57
  • 12
  • 58
  • 15

Source

The Navigation Timing API is similar to the Resource Timing API, but it reports only navigation requests. The navigation entry type is also similar to the resource entry type, but it contains some additional information specific to only navigation requests (such as when the DOMContentLoaded and load events fire).

One metric many developers track to understand server response time, Time to First Byte (TTFB), is available through the responseStart timestamp in the Navigation Timing API.

// Catch errors since  browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // If transferSize is 0, the resource was fulfilled using the cache.
      console.log('Time to first byte', entry.responseStart);
    }
  });
  // Start listening for `navigation` entries to be dispatched.
  po.observe({type: 'navigation', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}

Another metric developers who use service workers might care about is the service worker startup time for navigation requests. This is the amount of time it takes the browser to start the service worker thread before it can start intercepting fetch events.

The service worker startup time for a specified navigation request can be determined from the delta between entry.responseStart and entry.workerStart as follows:

// Catch errors some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.log('Service Worker startup time:',
          entry.responseStart - entry.workerStart);
    }
  });
  // Start listening for `navigation` entries to be dispatched.
  po.observe({type: 'navigation', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}

Server Timing API

The Server Timing API lets you pass request-specific timing data from your server to the browser using response headers. For example, you can indicate how long it took to look up data in a database for a particular request, which can be useful in debugging performance issues caused by slowness on the server.

For developers who use third-party analytics providers, the Server Timing API is the only way to correlate server performance data with other business metrics that these analytics tools measure.

To specify server timing data in your responses, use the Server-Timing response header. Here's an example:

HTTP/1.1 200 OK

Server-Timing: miss, db;dur=53, app;dur=47.2

Then, from your pages, you can read this data on both resource or navigation entries from the Resource Timing and Navigation Timing APIs.

// Catch errors some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
  // Create the performance observer.
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Logs all server timing data for this response
      console.log('Server Timing', entry.serverTiming);
    }
  });
  // Start listening for `navigation` entries to be dispatched.
  po.observe({type: 'navigation', buffered: true});
} catch (e) {
  // Do nothing if the browser doesn't support this API.
}