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.
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;
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
- 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.
- Half-trace rate near zero. Count traces whose only spans are client-side; a high rate means the sampling decision is being made twice.
traceparentsurvives the preflight. Check a cross-origin request in DevTools; a missing header usually means the CORS allowlist regex does not match.- Trace ids appear on RUM beacons, so the join above is possible without a separate lookup.
- 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.
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
00flag 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.
Related
- OpenTelemetry for Web RUM — the data model and sampling architecture behind this.
- Exporting Web Vitals as OpenTelemetry Spans — attaching the vitals to the span this guide correlates.
- FCP & TTFB Analysis — the metric the correlation is usually being built to explain.