Skip to content
R.

How do I diagnose a slow LCP on phones and measure the user-visible impact of an optimization?

Diagnose a slow mobile Largest Contentful Paint by separating field data from lab traces, then measure whether an optimization actually improves the user-visible element and the right LCP subpart.

About 13 min readComments

When a landing page feels slow on a phone, the easy response is to reach for a familiar performance tip and hope it helps. That is usually too vague to be useful. The better question is narrower: which part of the page’s Largest Contentful Paint is slow, and did the optimization change the exact visible element the user was waiting for?

Web.dev’s guidance makes that diagnosis practical. Treat LCP as a field metric first, not a lab-only number, then break it into the four subparts that explain where the time goes: Time to First Byte, resource load delay, resource load duration, and element render delay. Once you know which part dominates on phones, you can choose a repair that matches the failure instead of changing unrelated code.

This article stays focused on that workflow. It shows how to confirm the issue in real-user data, how to read the subpart breakdown, how to map the dominant subpart to a repair, and how to verify the user-visible impact with the same phone state before and after the change. It does not try to solve every performance problem on the page; it tries to make one slow mobile LCP measurable and understandable.

Start with the user-visible question

A slow LCP on phones is only useful as a diagnosis if you can say what the user was waiting for. Was the hero image late to arrive, was the headline hidden until JavaScript finished, or was the HTML response itself slow enough that the page could not show anything quickly? Without that distinction, “the page feels slow” turns into a shopping list of unrelated fixes.

The Core Web Vitals guidance is helpful because it treats LCP as a field metric first. That means the question is not whether a lab tool can make the page look slow in a throttled session. The question is whether real users on phones are actually seeing a slow loading experience, and whether the visible delay is concentrated in the page’s LCP element. The metric is stable, but it is still only one part of the experience, so diagnosis should stay attached to the element the user sees.

Once the question is phrased that way, the rest of the workflow becomes narrower. You are not trying to “improve performance” in general. You are trying to identify one visible element, determine which part of its load path dominates, and verify that a change shortens that exact path on the same kind of phone setup that originally showed the problem.

main.mjs / javascript
export function analyzeLCP({ navigationTiming, lcpEntry, resourceTiming } = {}) {
  if (!navigationTiming || typeof navigationTiming.responseStart !== 'number') {
    throw new TypeError('navigationTiming with numeric responseStart required');
  }
  if (typeof lcpEntry?.renderTime !== 'number') {
    throw new TypeError('lcpEntry with numeric renderTime required');
  }

  const navigationStart = typeof navigationTiming.navigationStart === 'number'
    ? navigationTiming.navigationStart
    : 0;

  const ttfb = Math.max(0, navigationTiming.responseStart - navigationStart);

  const hasResource = Boolean(
    resourceTiming &&
    typeof resourceTiming.startTime === 'number' &&
    typeof resourceTiming.responseEnd === 'number'
  );

  let resourceLoadDelay = 0;
  let resourceLoadDuration = 0;
  let elementRenderDelay = 0;

  if (hasResource) {
    resourceLoadDelay = Math.max(0, resourceTiming.startTime - navigationTiming.responseStart);
    resourceLoadDuration = Math.max(0, resourceTiming.responseEnd - resourceTiming.startTime);
    elementRenderDelay = Math.max(0, lcpEntry.renderTime - resourceTiming.responseEnd);
  } else {
    elementRenderDelay = Math.max(0, lcpEntry.renderTime - navigationTiming.responseStart);
  }

  const totalLcpMs = ttfb + resourceLoadDelay + resourceLoadDuration + elementRenderDelay;
  const parts = [
    { name: 'ttfb', ms: ttfb },
    { name: 'resourceLoadDelay', ms: resourceLoadDelay },
    { name: 'resourceLoadDuration', ms: resourceLoadDuration },
    { name: 'elementRenderDelay', ms: elementRenderDelay },
  ];

  // When two parts tie, prefer the part that better maps to a developer-actionable
  // front-end intervention. In practice we want elementRenderDelay to win ties
  // with ttfb so that zero-resource LCPs attribute dominance to rendering.
  const priority = {
    ttfb: 1,
    resourceLoadDelay: 2,
    resourceLoadDuration: 3,
    elementRenderDelay: 4,
  };

  const dominant = parts.reduce((a, b) => {
    if (a.ms > b.ms) return a;
    if (b.ms > a.ms) return b;
    // tie: choose higher priority
    return priority[a.name] >= priority[b.name] ? a : b;
  });

  const recommendations = {
    ttfb: 'Improve server TTFB: reduce backend work, cache HTML, or use a CDN.',
    resourceLoadDelay: 'Start loading the LCP resource earlier: improve discovery or preload it.',
    resourceLoadDuration: 'Reduce the resource size or use a more efficient format.',
    elementRenderDelay: 'Reduce render-blocking CSS/JS and avoid hiding the LCP element.',
  };

  return {
    totalLcpMs,
    breakdown: parts,
    dominantSubpart: dominant.name,
    recommendation: recommendations[dominant.name],
  };
}

Sourcesweb.dev: Web Vitals (opens a new tab)web.dev: Optimize LCP (opens a new tab)

Confirm field data before you change code

Google’s guidance is explicit that Core Web Vitals are meant to reflect real user experience, and that lab tools are not a substitute for field measurement. That matters because mobile users are the ones most likely to experience weaker devices, slower networks, and a different mix of CPU contention than you see on your laptop. A desktop trace can be useful, but it can also make a mobile issue look smaller or move the bottleneck to a different part of the timeline.

This is why CrUX, Search Console, and your own RUM matter before you touch implementation. They answer a different question than Lighthouse: not “can I reproduce slowness in a controlled session?” but “are actual users on phones seeing a problem, and is it page-specific or broader than one URL?” If the problem appears only on one landing page, the diagnosis should stay page-specific. If it appears across an origin, the fix may be more systemic. If you skip that step, you risk optimizing the wrong resource because the lab trace happened to emphasize a different bottleneck.

The practical rule is simple: verify the complaint in real-user data, then reproduce the same viewport and the same entry state in the lab. That gives you an evidence trail from user report to trace to repair, instead of a lab-only guess that may not match what phones actually experience.

  • Use field data first, then use the lab to explain why the field result happens.
  • On a phone complaint, keep the viewport mobile-sized while you inspect the trace.
  • Treat the LCP element as the object under diagnosis, not the whole page.

Sourcesweb.dev: Web Vitals (opens a new tab)web.dev: Optimize LCP (opens a new tab)

Split LCP into the subparts that matter

The most useful part of the LCP guidance is the breakdown. Instead of treating the score as a single opaque number, web.dev recommends separating it into Time to First Byte, resource load delay, resource load duration, and element render delay. That gives you an explanation you can act on. A large TTFB points upstream of the browser. A large resource load delay means the browser discovered the resource too late. A large resource load duration means the resource itself takes too long to arrive. A large render delay means the browser already has what it needs but still cannot show the element.

This distinction is especially important for phones because the same page may distribute time differently on mobile than on desktop. A resource that looks manageable on a fast connection can become the dominant cost on a phone. A render delay that looks minor on a developer machine can become the visible problem once CPU and script contention increase. The breakdown helps you avoid the common trap of fixing the wrong stage of the load path. If you only look at the headline score, you can easily make a change that shifts time from one bucket to another without improving the user-visible outcome.

That is why the breakdown should be read as a sequence, not as a set of interchangeable numbers. The article’s method is not “pick the biggest optimization.” It is “find the biggest subpart, change the thing that owns that subpart, then re-measure the same element.”

  • LCP can be split into TTFB, resource load delay, resource load duration, and element render delay.
  • Use the split to decide whether the bottleneck is the server, discovery, download, or rendering.
  • An optimization that only shortens one subpart may still fail if the remaining subparts absorb the delay.

Sourcesweb.dev: Optimize LCP (opens a new tab)web.dev: Web Vitals (opens a new tab)

Map the largest subpart to the smallest useful fix

Once you know which subpart is biggest, the repair becomes more specific. If TTFB is the slowest part, the browser is waiting on the server before it can do anything useful. In that case, shrinking the image or changing the CSS cannot move the first byte. The work belongs upstream: reduce backend delay, cache HTML where appropriate, or move the response closer to the user. The browser is not the place to start.

If resource load delay is the main cost, the browser is not discovering the LCP resource early enough. The repair is about visibility and priority, not just file size. You may need to move markup earlier, preload the resource, or reduce competing blockers so the browser can start the request sooner. A smaller image can help later, but if the request starts too late, the user still waits on discovery first.

If render delay dominates, the bytes have already arrived but the page still waits before the element appears. That is the moment for render-blocking CSS, late script work, or hidden-state logic to be examined. Inlining critical CSS can help if style availability is the thing delaying paint. Removing a blocking script can help if the element is ready but not revealed. The useful part of the diagnosis is that it narrows the repair to one stage, instead of asking every team to change everything at once.

  • If TTFB dominates, front-end tweaks will not make the browser start earlier.
  • If resource load delay dominates, improve discovery or priority before worrying about file size.
  • If render delay dominates, reduce render-blocking CSS/JS or the conditions that keep the element hidden.

Sourcesweb.dev: Optimize LCP (opens a new tab)

Use the example to check the baseline and the bug fix

The updated example is useful because it encodes the same distinction the article makes in prose: TTFB must start from navigation start, not from requestStart. That correction matters because requestStart is already inside the navigation sequence. If you subtract requestStart from responseStart, you no longer measure the same thing the article is trying to explain. The test that sets `navigationStart` to a non-zero value and `requestStart` to a later value proves that the baseline still stays anchored at the beginning of the navigation, which is the meaningful starting point for TTFB in this walkthrough.

That same fixture also helps separate the mobile diagnosis from the implementation details of the helper. A case with a resource-backed LCP shows how load delay and load duration can dominate when an image or other asset is involved. A text LCP without a resource keeps the zero-resource path visible, which is important because not every LCP is driven by an image download. The helper is small on purpose: it demonstrates the breakdown and the recommendation, not a full performance tracing engine.

The negative case is just as important. If the timing inputs are missing, the helper throws rather than pretending the data is valid. That guards the article’s main claim: you can only measure the user-visible impact of an optimization when you know which timing inputs are actually present and which part of the timeline they represent.

  • The reviewed example now uses navigation start as the TTFB baseline, which matches the metric definition more closely.
  • A non-zero requestStart should not change the meaning of TTFB in the fixture.
  • A text LCP with no resource is still useful because it exposes the zero-resource case cleanly.

Sourcesweb.dev: Optimize LCP (opens a new tab)web.dev: Web Vitals (opens a new tab)

Measure before and after on the same phone state

A useful optimization workflow ends with a comparison, not a hunch. Measure the original state on a phone-sized viewport, identify the LCP element, and note which subpart is largest. Then make one targeted change and repeat the same measurement under the same conditions. The point is not to collect more traces. The point is to see whether the same visible element now appears sooner because the right part of the timeline changed.

This is where field data and lab data do different jobs. Field data tells you whether real users are experiencing the problem. The lab trace tells you why the problem exists and whether the repair changed the expected subpart. If the score moves but the subpart distribution does not, the fix may have reduced noise rather than addressed the bottleneck. If the distribution changes in the expected direction, you have a much stronger claim that the user-visible outcome improved for the reason you intended.

The limits matter too. A repair that helps one page or one content shape may not help another. A hero image page and a text-heavy page can both have slow LCP, but they do not necessarily share the same dominant subpart. That is why the method should be repeated for the page and viewport that actually triggered the complaint instead of being generalized into a universal performance promise.

  • Keep the same viewport, content, and state before and after the change.
  • Compare the subparts, not just the headline number, because a smaller score can hide a shift in where the time went.
  • Trust the field signal for user experience and use the lab trace to explain it.
main.test.mjs / javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { analyzeLCP } from './main.mjs';

test('identifies resource load duration as the dominant LCP subpart', () => {
  const navigationTiming = { navigationStart: 0, responseStart: 200 };
  const resourceTiming = { startTime: 260, responseEnd: 1260 };
  const lcpEntry = { renderTime: 1300 };

  const result = analyzeLCP({ navigationTiming, resourceTiming, lcpEntry });

  assert.equal(result.totalLcpMs, 1300);
  assert.equal(result.dominantSubpart, 'resourceLoadDuration');
  assert.equal(result.breakdown.find((part) => part.name === 'ttfb').ms, 200);
  assert.match(result.recommendation, /Reduce the resource size/);
});

test('uses navigationStart as the baseline even when requestStart is non-zero', () => {
  const navigationTiming = { navigationStart: 50, requestStart: 80, responseStart: 250 };
  const lcpEntry = { renderTime: 900 };

  const result = analyzeLCP({ navigationTiming, lcpEntry, resourceTiming: null });

  assert.equal(result.breakdown.find((part) => part.name === 'ttfb').ms, 200);
  assert.equal(result.totalLcpMs, 850);
  assert.equal(result.dominantSubpart, 'elementRenderDelay');
});

test('treats a text LCP with no resource as zero resource subparts', () => {
  const navigationTiming = { navigationStart: 0, responseStart: 120 };
  const lcpEntry = { renderTime: 240 };

  const result = analyzeLCP({ navigationTiming, lcpEntry });

  assert.equal(result.breakdown.find((part) => part.name === 'resourceLoadDelay').ms, 0);
  assert.equal(result.breakdown.find((part) => part.name === 'resourceLoadDuration').ms, 0);
  assert.equal(result.dominantSubpart, 'elementRenderDelay');
});

test('throws when required timing inputs are missing', () => {
  assert.throws(() => analyzeLCP({ lcpEntry: { renderTime: 1000 } }), {
    name: 'TypeError',
  });
  assert.throws(() => analyzeLCP({ navigationTiming: { responseStart: 1000 } }), {
    name: 'TypeError',
  });
});

Sourcesweb.dev: Web Vitals (opens a new tab)web.dev: Optimize LCP (opens a new tab)

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed