Correlating Frontend Spans with Backend Traces

A slow TTFB tells you the server took a long time. A correlated trace tells you which service, which query and which downstream call took it. This guide sits under OpenTelemetry for Web RUM and covers propagating trace context from the browser into your backend, the CORS configuration that makes it possible cross-origin, sampling decisions that keep both halves of a trace, and what the joined data actually answers that neither half could alone.

Prerequisites

  • Browser OpenTelemetry configured, as in Configuring OpenTelemetry for Frontend Performance.
  • Backend services already instrumented and exporting to the same trace backend.
  • Control of CORS headers on your API, or a same-origin API path.
  • A shared understanding of the sampling rate on both sides — this is where most correlation attempts fail.

How to correlate them

Step 1 — Propagate the trace context

The W3C traceparent header carries the trace id and the parent span id. The browser SDK adds it automatically to instrumented fetch and XHR calls, but only for origins you explicitly allow.

// The allowlist is the part people miss: without it the header is stripped
// for cross-origin requests and the two halves never join.
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';

registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({
      propagateTraceHeaderCorsUrls: [
        /^https:\/\/api\.example\.com\//,
        /^\/api\//,
      ],
      // Adds server timing back into the client span where the API returns it.
      clearTimingResources: true,
    }),
  ],
});

Step 2 — Allow the header at the API

A cross-origin request carrying traceparent is not a simple request, so it triggers a preflight. The API has to allow the header explicitly or the browser drops the request entirely.

// Express-style CORS configuration for a traced API.
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', 'https://www.example.com');
  res.setHeader('Access-Control-Allow-Headers', 'content-type, traceparent, tracestate');
  // Expose Server-Timing so the browser can read backend detail without a join.
  res.setHeader('Access-Control-Expose-Headers', 'server-timing');
  res.setHeader('Timing-Allow-Origin', 'https://www.example.com');
  res.setHeader('Access-Control-Max-Age', '86400');   // cache the preflight
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

Timing-Allow-Origin is worth setting at the same time: without it, Resource Timing entries for that origin are opaque and every phase reads as zero, which looks like a fast request rather than a missing measurement.

Step 3 — Correlate the document request too

The API path is the easy half. The document request happens before any JavaScript runs, so the browser cannot add a header to it — the propagation has to go the other way, with the server writing its trace id into the HTML.

// Server-side: stamp the trace id into the document so the client can adopt it.
app.get('/*', (req, res) => {
  const span = trace.getActiveSpan();
  const ctx = span?.spanContext();
  res.send(renderPage({
    traceparent: ctx ? `00-${ctx.traceId}-${ctx.spanId}-01` : null,
  }));
});
<!-- In the document head, before any instrumentation runs. -->
<meta name="traceparent" content="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01">
// Client-side: adopt it as the parent of the document-load span.
import { propagation, context, trace } from '@opentelemetry/api';

const tp = document.querySelector('meta[name="traceparent"]')?.content;
const parent = tp
  ? propagation.extract(context.active(), { traceparent: tp })
  : context.active();

const tracer = trace.getTracer('web');
const loadSpan = tracer.startSpan('document-load', {}, parent);

This is what makes a slow TTFB actionable: the document-load span becomes a child of the server’s request span, so the waterfall shows the browser’s wait and the server’s work as one trace.

Where the trace context has to crossThe document direction is downward and manual; the API direction is upward and automatic. Most setups do only the second and wonder why TTFB is unexplained.Where the trace context has to crossTwo propagation directions, one traceServer rendercreates the traceHTML metacarries traceparent downBrowser spansadopt it as parentAPI fetchsends traceparent up
The document direction is downward and manual; the API direction is upward and automatic. Most setups do only the second and wonder why TTFB is unexplained.

Step 4 — Make the sampling decision once

The most common failure in trace correlation is a half-trace: the browser sampled a session in, the backend sampled the same request out, and the trace has a client span with no server children. Head-based sampling with the decision made once and propagated is the fix.

Where the decision is made Result Use when
Server, propagated in traceparent Complete traces Server-rendered pages
Browser, propagated to the API Complete traces for API calls SPA with a separate API
Both independently Half-traces, unusable Never
Collector, tail-based Complete, expensive High-value debugging only

The sampled flag is the last byte of traceparent (01 sampled, 00 not). Respect it on both sides rather than re-deciding.

Step 5 — Query the joined data

With both halves under one trace id, the questions that were impossible become ordinary.

-- Which backend service explains the slow TTFB sessions?
WITH slow AS (
  SELECT trace_id
  FROM rum_events
  WHERE metric = 'TTFB' AND value > 1500 AND ts >= now() - INTERVAL 1 DAY
)
SELECT
  s.service_name,
  count()                                  AS spans,
  quantileTDigest(0.75)(s.duration_ms)     AS p75_ms,
  quantileTDigest(0.95)(s.duration_ms)     AS p95_ms
FROM spans s
INNER JOIN slow USING (trace_id)
WHERE s.parent_span_id IS NOT NULL
GROUP BY s.service_name
ORDER BY p75_ms DESC
LIMIT 10;
What the join reveals about a 1.6 s TTFBThe rendering layer everyone suspected accounts for 110 ms. A single upstream call accounts for more than half the wait.What the join reveals about a 1.6 s TTFBp75 span duration inside slow-TTFB traces, by servicecatalog-api940 mspricing-service420 mssession-service180 msrender110 ms0 ms250 ms500 ms750 ms1,000 ms
The rendering layer everyone suspected accounts for 110 ms. A single upstream call accounts for more than half the wait.

Step 6 — Keep the volume under control

Traces are the most expensive telemetry you will collect. Two policies keep the bill proportionate: sample the document trace at a low rate for baseline visibility, and add a tail-based rule at the Collector that keeps 100% of traces containing an error or an unusually slow span. The SDK-versus-Collector trade-off is covered in OpenTelemetry for Web RUM.

Resource spans deserve their own mention: browser instrumentation can emit a span per subresource, which on an image-heavy page is dozens per load. Filter them at the Collector unless you are actively investigating, or they will dominate both the storage and the reading experience.

What correlation is actually worth

Three questions become answerable, and they are the ones that previously ended in a shrug between two teams.

Which service caused the TTFB regression? Without correlation this is a conversation; with it, it is the query above.

Does backend latency explain the field metric, or only part of it? Comparing the span duration against the browser’s TTFB shows the network and queueing time that no backend dashboard contains. A 940 ms span inside a 1,600 ms TTFB means 660 ms is not the service at all.

Which users experienced the incident? Traces carry cohort attributes from the browser side, so an incident can be scoped to device tier, country or route rather than described as “elevated latency”.

The honest caveat: correlation is expensive and most teams do not need every trace joined. A 1% baseline sample plus tail-based retention of the interesting ones answers all three questions at a fraction of the cost, and is the configuration to start from.

Verifying it works

  1. A trace picked at random contains both halves — a document-load span with server children. This is the end-to-end test, and it should be part of the release checklist.
  2. Half-trace rate near zero. Count traces whose only spans are client-side; a high rate means the sampling decision is being made twice.
  3. traceparent survives the preflight. Check a cross-origin request in DevTools; a missing header usually means the CORS allowlist regex does not match.
  4. Trace ids appear on RUM beacons, so the join above is possible without a separate lookup.
  5. Span volume is bounded — watch the resource-span count per page load and filter at the Collector before it becomes the largest line in the bill.
Trace completeness by sampling designDeciding independently on each side produces mostly half-traces, which are unusable for exactly the join the whole exercise exists to enable.Trace completeness by sampling designShare of traces containing both client and server spansIndependent decisions11%46%43%Server decides, propagated96%Tail sampling at the Collector99%CompleteClient onlyServer only
Deciding independently on each side produces mostly half-traces, which are unusable for exactly the join the whole exercise exists to enable.

Edge cases and gotchas

  • The document request cannot carry a client-generated header. Propagate downward via the HTML; there is no other route.
  • Sampled-out traces still cost something. The context propagates regardless, and a 00 flag means the backend will not record — which is the intent, but it also means a debugging session needs the flag flipped end to end.
  • Clock skew between client and server makes span timelines look impossible. Trace backends usually correct for it; verify rather than assume.
  • Ad blockers and privacy extensions block collector endpoints on known domains. Self-hosting the collector on your own origin materially improves coverage.
  • Trace ids are identifiers. They are random and short-lived, but they link a browser session to backend logs — treat them under the same policy as other session identifiers, per Privacy-Compliant Tracking.
  • Service meshes may rewrite headers. A mesh that generates its own trace context breaks the chain at the ingress; configure it to respect an incoming traceparent.

FAQ

Can I correlate the document request without server-side rendering?

Not directly — the browser has no way to attach a header to the navigation. A static page served from a CDN has no server span to correlate with either, which is usually fine because there is nothing to explain.

What if the browser and backend use different sampling rates?

You get half-traces. Make the decision once, propagate the sampled flag, and honour it on both sides.

Is Server-Timing a simpler alternative?

For many teams, yes. Server-Timing puts a few backend durations directly into the response headers where the browser can read them, with no trace backend involved. It answers “how long did the server take” but not “which service”.

How much does this cost?

Traces are typically the largest telemetry line item. A low baseline sample with tail-based retention of slow and errored traces keeps it proportionate while preserving the cases you actually open.