Web vitals

Measuring Largest Contentful Paint in Single-Page Applications

Florian Bücklers
June 18, 2025
8 min read
Contact our team
Let's check your website speed.
Contact sales
Share blog post
Everyone benefits from speed.
https://speedkit.com/blog/measuring-largest-contentful-paint-in-single-page-applications

Your SPA Is Faster Than You Think: A Custom Solution for Measuring LCP

You've shipped it. Your new Single-Page Application (SPA) is a marvel of modern engineering. Lighthouse scores are green, your bundle size is optimized, and yet... the feedback rolls in: "It feels sluggish when I click around."

This frustrating disconnect between your metrics and your users' reality is a classic sign that your Core Web Vitals are lying to you. The culprit? Largest Contentful Paint (LCP), a metric fundamentally broken by the very nature of SPAs.

The Problem: LCP and the Blind Spot of "Soft Navigations"

LCP is the single most important metric for perceived loading speed. It marks the moment the main content of a page becomes visible to the user. For traditional websites, where each click triggers a full page load, this is easy to measure.

But SPAs are different. They use "soft navigations"—dynamically rewriting the page with JavaScript instead of requesting a new one from the server. To the browser's performance observers, your entire application is one long, single page load. It measures the LCP for the initial landing but becomes completely blind to the critical LCP of subsequent views loaded in-app.

This is a massive blind spot. If your "products" page takes four seconds to show its hero image after a user clicks from the homepage, that's a four-second LCP that is completely invisible to standard tools.

The Chrome team is aware of this and ran an initial origin trial to start reporting on soft navigation LCP. We eagerly tested it, but the trial was paused as they plan to improve it further before running another iteration. This created a gap: we needed to continue measuring the soft LCP for our customers, but the official solution was on hold. To fill this gap, we developed our own algorithm.

A Custom Solution: The Hunt for the Soft LCP

To solve this LCP conundrum, we need to replicate the browser's own LCP detection logic, but scope it to a soft navigation. Our approach dives deep into the browser's rendering pipeline.

Intercepting Image Creation

To measure the soft LCP, we first need to identify potential LCP image candidates as they are being created by the SPA framework. The core of our algorithm achieves this by intercepting the src property setter on the global HTMLImageElement.prototype.

However, instrumenting every image on a page - including tiny icons, spacers, and third-party tracking pixels - would be inefficient and noisy. The provided code is more intelligent. As seen in the setupSoftLCPObserver function, the mechanism is initialized with an imageUrls array. This acts as an allowlist.

Our custom src setter only takes action if two conditions are met:

  1. A soft navigation measurement is currently active (observeImageElements is true).
  2. The image's src URL contains one of the strings from the imageUrls allowlist (e.g., your CDN's domain).

When a relevant image is identified, it is "instrumented." This involves two critical steps performed by the observeImagePaint function in the code:

  1. The elementtiming attribute is added to the image element. This explicitly tells the browser to monitor this specific element and report its paint timing via the PerformanceElementTiming API.

The element is added to a set of imageCandidates. This set holds all the potential LCP elements for the current soft navigation, which are then passed to the paint-time approximation logic for further analysis.

// Simplified from the main script for clarity
const originalImageSrcSetter = Object.getOwnPropertyDescriptor(
  HTMLImageElement.prototype,
  'src'
)!.set!;

// The 'imageUrls' array is provided during setup
const imageUrls = ["my-cdn.domain.com", "other-images.domain.com"];
let observeImageElements = false; // This is true only during a soft nav

function observeImagePaint(image, src) {
  // Only act on relevant images during a soft navigation
  if (observeImageElements && imageUrls.some(matcher => src.includes(matcher))) {
    // 1. Opt the image into PerformanceElementTiming
    image.setAttribute("elementtiming", "");
    
    // 2. Add to a list for paint time analysis
    // imageCandidates.add(image);
    // ... logic to start observing for paints ...
  }
}

Object.defineProperty(HTMLImageElement.prototype, 'src', {
  set(src) {
    // Our custom logic runs first
    observeImagePaint(this, src);
    // Then, we call the original setter to maintain default browser behavior
    originalImageSrcSetter.call(this, src);
  },
});


This targeted interception strategy is highly efficient. It ensures we only monitor the paint timing for meaningful content images, providing a clean and relevant set of candidates for determining the true soft navigation LCP.

Approximating Paint Time

To accurately determine when an image is painted, our solution must overcome a key shortcoming of the PerformanceElementTiming API, especially within the dynamic lifecycle of an SPA. While this API is the ideal tool for observing an element's actual paint time, its behavior can be inconsistent if an image is already loaded before it's attached to the DOM. This can happen if the image is served from the browser's cache, or because modern browsers often start downloading an image as soon as its element is created in memory, even before it is added to the page. In these common scenarios, the browser may not emit an element timing entry, leaving us blind.

Our algorithm solves this with a robust, dual-pronged approach using requestAnimationFrame and IntersectionObserver.

Here’s how it works step-by-step:

  1. Identify Candidates: When the image creation interception logic detects a new image, it's added to a set of potential LCP candidates.
  2. Monitor Browser Paints: We then start a requestAnimationFrame loop. This is critical because it allows us to execute code at the precise moment just before the browser performs a new paint.
  3. Check Image Status at Paint Time: Within each requestAnimationFrame callback, we check our candidate images. For any image that has been connected to the DOM, we determine its loading status (image.complete). This check leads to two distinct paths:
    • The Happy Path (Image Not Yet Loaded): If image.complete is false, it means the image is still being fetched from the network when it's rendered. In this case, we can confidently rely on the standard PerformanceObserver to fire an element timing entry. This entry will provide the most accurate renderTime once the image is fully loaded and painted.
    • The Fallback Path (Image Already Loaded): If image.complete is true, we are in the problematic scenario. To capture this timing, we immediately register the element with an IntersectionObserver. This observer will trigger as soon as the element becomes visible in the viewport, providing a high-resolution timestamp (entry.time). We use this timestamp as a very close and reliable approximation of its paint time.
    • Once an image candidate is connected to the DOM and processed via one of these two paths, it is removed from our set of active candidates. As an optimization: The requestAnimationFrame loop will automatically stop once all pending images have been handled, preventing any unnecessary monitoring.

This dual strategy ensures that no matter how or when an image is loaded, we can capture a precise and meaningful timestamp for when it is actually painted on the screen. By combining the strengths of PerformanceElementTiming, requestAnimationFrame, and IntersectionObserver, we create a resilient system that accurately measures the paint time for LCP candidates in a soft navigation.

Resource Timings and Caching

The script also observes resource timings to gather detailed attribution data for the LCP, such as loadDelay, loadDuration, and renderDelay. This is crucial for understanding the performance characteristics of the soft navigation.

However, a key consideration is browser caching. If an image has been previously loaded (even during a hard navigation), it might be served from the browser's in-memory cache. In such cases, no new resource timing will be generated, and the LCP will be entirely attributed to renderDelay. This is an important nuance to keep in mind when analyzing soft LCP data.

Limitations of this Approach

While this algorithm provides a powerful way to approximate soft LCP, it's important to be aware of its limitations:

  • Text Nodes are Not Measured: This implementation focuses exclusively on image elements as LCP candidates. The standard LCP metric can also identify text blocks as the largest element. This algorithm does not account for text-based LCP, which might be the true LCP in some soft navigations.
  • Browser Compatibility: The solution relies heavily on the PerformanceElementTiming API. As of now, this API is primarily supported in Chromium-based browsers (like Google Chrome and Microsoft Edge). Therefore, this LCP measurement technique will not work in browsers like Firefox or Safari.
  • Scroll Restoration Issues: SPA frameworks often handle scroll restoration, attempting to return the user to their previous scroll position on a new view. This can create measurement challenges. For instance, when navigating back, content for the new page might start painting before the scroll position is reset. This can lead to LCP elements being reported as off-screen, as they are technically in the DOM but scrolled out of view by the restoration algorithm, potentially skewing the results.

Framework Integration for Accurate Timing

For the most accurate LCP measurement, it's essential to signal the start of a soft navigation at the precise moment it begins. Relying on the History API is often too late, as many frameworks only update the history after the necessary resources have been loaded and the page is ready to render. This can lead to deceptively low LCP times.

The setupSoftLCPObserver function returned by the script allows for an optional startTime to be passed to the triggerSoftNavigation function. This enables a more precise measurement based on the actual start of your navigation. Here's an example of how this can be implemented in a Nuxt.js application:

let nuxt = window.useNuxtApp();
// https://nuxt.com/docs/api/advanced/hooks

nuxt.hook("page:start", () => triggerSoftNavigation(performance.now()));


By hooking directly into your framework's navigation lifecycle, you can achieve a much more accurate and representative measurement of your application's soft LCP, providing valuable insights into the user's perceived performance.

Try It Yourself

Ready to stop guessing and start measuring? You can find the complete, annotated code for this solution in the following GitHub Gist. We encourage you to experiment with it in your own SPA. View the code on GitHub Gist.

Implement it, analyze the data you collect, and see what you discover about your application's soft navigation performance. What challenges did you face? How did this new insight change your optimization strategy? Share your findings and help push the conversation forward.

GET STARTED

Book a free website speed check

We analyze your website speed, identify web vitals issues, and compare your competitors.

Book free speed check
iPhone 15 Device CheckLaser Scanner