Turning the Performance API into Your Own Core Web Vitals Reporter
The Performance API provides a standardized set of JavaScript interfaces for measuring page speed and rendering behavior. It gives developers the same underlying data used by popular auditing tools, and we can tap into it directly with the `performance.getEntries()` method or a `PerformanceObserver` instance to build custom dashboards for Core Web Vitals.
The PerformanceObserver route is the better option for several reasons. It returns metrics asynchronously as they are recorded, so it doesn't block the main thread. It can also capture metrics that were queued before you started observing by setting buffered: true. Finally, some entry types, like element, are not available through the older performance.getEntries() method.
const lcpObserver = new PerformanceObserver(list => {});
Before diving into specific metrics, be aware of browser support. Chromium-based browsers support the full set of Core Web Vitals properties. Firefox supports the paint entries needed for First Contentful Paint (FCP) and Largest Contentful Paint (LCP). For everything else, particularly layout shift and Interaction to Next Paint (INP), you will need to test in a Chromium browser.
Reading the Largest Contentful Paint
The largest-contentful-paint entry type identifies the biggest piece of content rendered in the initial viewport and reports how long it took to paint. We can start observing this metric by passing the type into our observer configuration.
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
With the observer in place, we fill in the callback to receive the list of performance entries.
// The Performance Observer const lcpObserver = new PerformanceObserver(list => {// Returns the entire list of entriesconst entries = list.getEntries();}); // Call the Observer lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
Because the LCP element is always the last entry in the list, we can grab it to log the details. The entry provides both the element's selector and its render time.
// The Performance Observer const lcpObserver = new PerformanceObserver(list => { // Returns the entire list of entries const entries = list.getEntries();// The element representing the LCPconst el = entries[entries.length - 1];}); // Call the Observer lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
// The Performance Observer const lcpObserver = new PerformanceObserver(list => { // Returns the entire list of entries const entries = list.getEntries(); // The element representing the LCP const el = entries[entries.length - 1];// Log the results in the consoleconsole.log(el.element);}); // Call the Observer lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
// The Performance Observer
const lcpObserver = new PerformanceObserver(list => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
entries.forEach(entry => {
// Log the results in the console
console.log(
`The LCP is:`,
lcp.element,
`The time to render was ${entry.startTime} milliseconds.`,
);
});
});
// Call the Observer
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
// The LCP is:
// <h2 class="author-post__title mt-5 text-5xl">…</h2>
// The time to render was 832.6999999880791 milliseconds.
Tracking First Contentful Paint
FCP measures the time until the browser paints the first bit of content from the DOM. To access this, we observe the paint entry type, which taps into the PerformancePaintTiming interface.
// The Performance Observer
const paintObserver = new PerformanceObserver(list => {
const entries = list.getEntries();
entries.forEach(entry => {
// Log the results in the console.
console.log(
`The time to ${entry.name} took ${entry.startTime} milliseconds.`,
);
});
});
// Call the Observer.
paintObserver.observe({ type: "paint", buffered: true });
// The time to first-paint took 509.29999999981374 milliseconds.
// The time to first-contentful-paint took 509.29999999981374 milliseconds.
Note that the paint type returns two entries: first-paint and first-contentful-paint. The distinction, according to the spec, is that First Paint marks when the browser renders anything at all to prevent a blank screen (such as a background color), while FCP marks when it renders the first image or text content. If your results are identical, it means the first visible content was also the first contentful piece.
Also keep in mind that Chrome has changed how it computes FCP across versions. Google maintains a full changelog of these implementation changes, and being aware of them helps explain discrepancies between reports from different browsers or team members.
Measuring Layout Shift
To quantify how much the page moves around during load, we turn to the layout-shift entry type from the LayoutShift interface. This API is experimental, so the hasRecentInput boolean and lastInputTime property help exclude shifts caused by user interactions like keydown, pointerdown, or mousedown that occurred within the last 500ms.
const observer = new PerformanceObserver((list) => {
let cumulativeLayoutShift = 0;
list.getEntries().forEach((entry) => {
// Don't count if the layout shift is a result of user interaction.
if (!entry.hadRecentInput) {
cumulativeLayoutShift += entry.value;
}
console.log({ entry, cumulativeLayoutShift });
});
});
// Call the Observer.
observer.observe({ type: "layout-shift", buffered: true });
When we query an entry, we get a shift score and the specific elements involved. The result object shows the severity of the shift, the offending selector, and the exact coordinates of the element's bounding box from its start position to its end position.
Capturing Interaction to Next Paint
INP measures the delay between a user's interaction and the page responding. It is set to replace First Input Delay (FID) as a Core Web Vitals metric soon. We can observe INP-related data through the PerformanceEventTiming class.
This entry type exposes the event type and name, the time it occurred, and the element that was interacted with. It also offers processingStart and processingEnd timestamps, letting you break down exactly how long the browser spent handling the event before the next frame.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
// Alias for the total duration.
const duration = entry.duration;
// Calculate the time before processing starts.
const delay = entry.processingStart - entry.startTime;
// Calculate the time to process the interaction.
const lag = entry.processingStart - entry.startTime;
// Don't count interactions that the user can cancel.
if (!entry.cancelable) {
console.log(`INP Duration: ${duration}`);
console.log(`INP Delay: ${delay}`);
console.log(`Event handler duration: ${lag}`);
}
});
});
// Call the Observer.
observer.observe({ type: "event", buffered: true });
Digging Into Long Animation Frames
While INP tells you how slow an interaction was, the Long Animation Frames API helps explain why. A long-animation-frame entry is reported whenever the browser is too busy processing to render content. It includes an overall frame duration as well as separate timings for each script that contributed to the blockage.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 50) {
// Log the overall duration of the long frame.
console.log(`Frame took ${entry.duration} ms`)
console.log(`Contributing scripts:`)
// Log information on each script in a table.
entry.scripts.forEach(script => {
console.table({
// URL of the script where the processing starts
sourceURL: script.sourceURL,
// Total time spent on this sub-task
duration: script.duration,
// Name of the handler function
functionName: script.sourceFunctionName,
// Why was the handler function called? For example,
// a user interaction or a fetch response arriving.
invoker: script.invoker
})
})
}
});
});
// Call the Observer.
observer.observe({ type: "long-animation-frame", buffered: true });
When an INP interaction runs long, you can correlate it with the closest long animation frame to identify which scripts delayed the response.
Wrapping It With the web-vitals Library
The Performance API is broad, covering resource timing, navigation timing, and custom reporting. If Core Web Vitals are your primary concern, the web-vitals library is a convenient wrapper around these browser APIs.
Getting each metric is a single function call.
webVitals.getINP(function(info) {
console.log(info)
}, { reportAllChanges: true });
The reportAllChanges option ensures you receive the metric on every update rather than waiting for the final value. For INP, this is useful because while the page is open, a slower interaction could always occur. Without that flag, the metric only reports when the page is unloaded or hidden.
The library can also give you the difference between consecutive reports—useful for tracking how metrics evolve during the page's lifetime.
function logDelta({ name, id, delta }) {
console.log(`${name} matching ID ${id} changed by ${delta}`);
}
onCLS(logDelta);
onINP(logDelta);
onLCP(logDelta);
From Measurement to Ongoing Monitoring
The examples in this series show how much raw power sits behind the Performance API. With a bit of JavaScript, you can pull real user data for the core metrics on demand. Still, capturing a snapshot is only the first step. The bigger challenge is tracking how those numbers trend over time and spotting regressions before they affect search rankings or business metrics.
You could stitch together a custom real user monitoring (RUM) tool on top of the Performance API and compare the results against historical baselines from the Chrome User Experience Report (CrUX). That is a valid path if you have the resources and the appetite for building dashboards, alerting, and data storage. For most teams, however, a turnkey solution moves faster.
That is where a service like DebugBear comes in. It bundles the metrics, history, and charts into a single interface, giving you real-time visibility into actual visitor experiences. The same data pipeline that powers a manual breakdown can answer deeper questions: which elements are users interacting with when INP spikes? What content shifts around on the page when CLS jumps? And is the LCP typically an image, a heading, or another type of element—and does that element type correlate with the LCP value you see?
DebugBear also makes use of the Long Animation Frames API discussed earlier. When an interaction feels slow, you can drill into the specific long tasks and identify which code is responsible for the delay, rather than guessing at the cause.
Resource-level detail is another benefit. The Performance API exposes every request a page makes, and DebugBear renders that as a request waterfall. The chart indicates whether resources were render-blocking, served from cache, or used for the LCP element. In this example, the blue line marks the FCP and the red line marks the LCP, showing that the LCP completes right after the image request labeled with the blue "LCP" badge finishes.
Should you run into a regression, the service can alert you ahead of time—before slow metrics start dragging down Google search visibility. You can evaluate whether this workflow fits your needs with a 14-day free trial that covers page speed analysis, suggested improvements, and Core Web Vitals tracking.




