Connecting Web Vitals to Backend Spans

Record LCP, INP, and CLS as attributes on a long-lived page-view span that shares its trace with the page’s API calls — then a query grouping backend latency by LCP bucket answers “is the slow page caused by our services or by the client”.

Context and when it matters

Real-user monitoring and distributed tracing usually live in separate tools and disagree constantly. RUM says the 75th-percentile LCP is 4.1 s; the tracing backend says every API call is under 150 ms. Both are correct, and neither can explain the other, because there is no identifier joining a page view to the requests it made.

Putting the vitals on a span in the same trace as those requests makes the join trivial. It also changes what you can ask: not just “how slow is LCP” but “for the page views with the worst LCP, what did the backend do differently” — which is the question that distinguishes a slow database from a heavy JavaScript bundle.

The timing problem

Web Vitals are not available when the page loads. LCP finalises when the user interacts or the page is hidden. INP requires at least one interaction and updates over the session. CLS accumulates until the page is hidden. A span that ends at load cannot carry any of them.

When each vital is final, and how long the span must live A timeline from navigation start to page hidden. TTFB is final at 320 milliseconds, FCP at 900, LCP at 2100 but may be revised until interaction, INP after the first interaction at 4200, and CLS only when the page is hidden. A page-view span spanning the whole timeline is the only span that can carry all five. A span that ends at load() can carry TTFB and FCP — and nothing else page-view span — navigation start to page hidden TTFB 320 ms FCP 900 ms LCP 2100 ms INP after interaction CLS on hidden load event fires here documentLoad span ends too early for LCP, INP, CLS Keep a page-view span open for the visit, set attributes as each metric finalises, and end it on visibilitychange.

Implementation

import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals/attribution';
import { trace, context } from '@opentelemetry/api';

const tracer = trace.getTracer('web.vitals');

// One long-lived span per page view. It shares the trace with every fetch the
// page makes, which is the whole point.
const pageView = tracer.startSpan('page view', {
  attributes: {
    'page.route': routeTemplate(location.pathname),   // TEMPLATE, not the URL
    'page.referrer_type': document.referrer ? 'internal' : 'direct',
    // Client conditions that explain a slow metric without being personal data.
    'network.effective_type': navigator.connection?.effectiveType ?? 'unknown',
    'device.hardware_concurrency': navigator.hardwareConcurrency ?? 0,
  },
});
const pageCtx = trace.setSpan(context.active(), pageView);

function record(metric) {
  // Value plus rating: the rating is bounded (good/needs-improvement/poor)
  // and is what you group by; the value is what you compute percentiles from.
  pageView.setAttribute(`web_vitals.${metric.name.toLowerCase()}`, metric.value);
  pageView.setAttribute(`web_vitals.${metric.name.toLowerCase()}.rating`, metric.rating);

  // Attribution turns "LCP is 4s" into "LCP is 4s because of this element,
  // and 2.6s of it was resource load delay".
  const a = metric.attribution ?? {};
  if (metric.name === 'LCP') {
    pageView.setAttribute('web_vitals.lcp.element', a.element ?? 'unknown');
    pageView.setAttribute('web_vitals.lcp.ttfb', a.timeToFirstByte ?? 0);
    pageView.setAttribute('web_vitals.lcp.resource_load_delay', a.resourceLoadDelay ?? 0);
    pageView.setAttribute('web_vitals.lcp.render_delay', a.elementRenderDelay ?? 0);
  }
  if (metric.name === 'INP') {
    pageView.setAttribute('web_vitals.inp.target', a.interactionTarget ?? 'unknown');
    pageView.setAttribute('web_vitals.inp.input_delay', a.inputDelay ?? 0);
    pageView.setAttribute('web_vitals.inp.processing_duration', a.processingDuration ?? 0);
  }
  if (metric.name === 'CLS') {
    pageView.setAttribute('web_vitals.cls.largest_shift_target', a.largestShiftTarget ?? 'unknown');
  }
}

[onTTFB, onFCP, onLCP, onINP, onCLS].forEach((fn) => fn(record));

// End the span when the visit ends, and flush before the tab goes away.
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden' && pageView.isRecording()) {
    pageView.end();
    provider.forceFlush().catch(() => {});
  }
}, { once: true });

Two details make this work in practice. page.route must be the route template rather than the URL, for the same cardinality reason as http.route on the server side. And the attribution import (web-vitals/attribution) is a larger bundle than the base one — worth it, because a metric without attribution tells you there is a problem and nothing about where it is.

Making the join useful

With the vitals on a span in the same trace as the API calls, the analysis that was previously impossible becomes one query:

# Backend latency for page views in the worst LCP bucket
{ resource.service.name = "dashboard-web" && span.web_vitals.lcp.rating = "poor" }
  >> { span:kind = server && resource.service.name != "dashboard-web" }
  | by(resource.service.name) | avg(span:duration)

# Compare against the same query for rating = "good".
# If backend latency is identical in both, the problem is client-side.

That comparison is the point of the whole exercise. Three outcomes, three different owners:

What the comparison tells you Three rows. If backend latency is much higher for poor LCP page views, the cause is server side. If backend latency is identical but LCP render delay is high, the cause is client rendering or bundle size. If time to first byte dominates and backend latency is low, the cause is network or edge. Poor LCP versus good LCP — what differs? backend latency much higher → server-side cause · the trace already names the slow service and query backend identical, render delay high → client-side cause · bundle size, hydration, or the LCP element itself TTFB dominates, backend fast → network or edge cause · check CDN cache hit ratio and connection type

Verification

  • Open one trace and confirm the page-view span carries all five metrics and shares its trace ID with the page’s API spans.
  • Compare the LCP distribution against your RUM tool for the same window; a systematic difference usually means the browser sample rate differs from what you assumed.
  • Check that spans arrive from real sessions, not just from the ones that stay open — if only long sessions report, the unload flush is not working.
  • Confirm page.route is bounded: a distinct-count query should return the number of routes, not the number of visits.

What to keep, and how much of it

Browser telemetry volume scales with users rather than with your capacity, so a sampling policy is not optional. The useful asymmetry is that the interesting page views are rare: a session with a poor LCP is a few percent of traffic, and those are the ones worth every byte.

That suggests a two-tier policy. Sample page views uniformly at a low rate — five percent is generous for trend analysis — and additionally keep every page view whose LCP or INP rating is poor. Because the rating is known before the span ends, the decision can be made in the browser: set the sampled flag on the page-view span when a metric crosses the threshold, and let the uniform sampler handle the rest. The result is a dataset where the aggregate is statistically sound and the tail is complete.

There is a subtlety worth planning around. The page-view span’s sampling decision is made at span creation, before any vital is known, so a late upgrade to “keep this one” requires either a tail-based decision at the Collector or a deliberate design where the page-view span is always sampled and only the associated fetch spans are thinned. The second option is usually simpler and cheaper: one span per page view is a small volume even at full retention, and the fetch spans are where the bulk sits.

Retention deserves a separate decision from backend traces. A page-load profile stops being actionable once the bundle changes, which for an actively developed front end is a matter of days. Routing browser telemetry to a shorter-retention tenant keeps cost proportionate and stops a traffic spike in the browser tier from evicting backend traces that are still under investigation.

Finally, be deliberate about what a browser span may carry. Attribution data names DOM elements, which is fine; but URLs, query strings, and any user-entered value are as sensitive here as anywhere else, and a browser is the easiest place for one to slip in. Template the route, avoid recording the full URL, and treat the same redaction discipline as applying to front-end telemetry.

Share of page views by LCP rating, and where to spend retention Share of page views by LCP rating, and where to spend retention. good (under 2.5 s) 72% — sample lightly; needs improvement 21% — sample; poor (over 4 s) 7% — keep all Share of page views by LCP rating, and where to spend retention good (under 2.5 s) 72% — sample lightly needs improvement 21% — sample poor (over 4 s) 7% — keep all Keeping every poor page view costs a fraction of total volume and covers every session worth investigating.

Common pitfalls

  • Ending the page-view span at load. Then LCP, INP, and CLS are all missing, and the exercise achieves nothing.
  • Creating one span per metric. Five sibling spans cannot be compared to each other and clutter the waterfall; the metrics belong on one span as attributes.
  • Recording the URL as page.route. Unbounded, and it makes every grouping useless.
  • Forgetting visibilitychange on mobile. iOS frequently skips beforeunload, so metrics from mobile sessions never arrive and the data skews desktop.
  • Sampling browser traces uniformly. The interesting page views are the slow ones; if your sample rate is low, consider always keeping sessions whose LCP rating is poor, which is a small fraction of traffic.

A closing note on interpretation: Web Vitals are percentile metrics defined over a population, so a single trace’s LCP is a data point rather than a verdict. Use individual traces to understand why a page view was slow, and the aggregate to decide whether it matters.


Related

↑ Back to Browser-to-Backend Trace Continuity