Browser-to-Backend Trace Continuity

Problem Framing

The backend trace for GET /api/dashboard is 120 ms end to end and looks perfect. The user says the dashboard takes four seconds to appear. Both are true: the browser spent 2.1 s parsing and executing JavaScript before it issued the request, then 900 ms rendering after the response arrived, and the API call itself was never the problem. The trace shows none of that, because the trace begins at the load balancer.

Real user latency starts with a click, not with an HTTP request reaching your ingress. Without browser-to-backend continuity, front-end and back-end teams argue from separate data sets — the RUM tool says the page is slow, the tracing backend says every service is fast, and nothing connects a slow page view to the specific database query that caused it.

Continuity means one trace ID spanning the browser’s document-load span, its fetch spans, and every backend span those requests trigger. This page covers how to establish that link, the CORS mechanics that trip everyone up, and the trust decisions you must make before accepting context from a client you do not control.

Prerequisites

Concept Deep-Dive: Where the Trace Really Begins

The browser produces three kinds of span, and only one of them ordinarily reaches your backend.

One trace, from document load through the backend A horizontal timeline. The document load span runs from 0 to 2100 milliseconds in the browser. A click interaction span follows. The fetch span starts at 2400 milliseconds and carries traceparent across the network boundary, where the server span, database span, and cache span nest beneath it, all sharing one trace ID. trace_id 4bf92f… shared by every bar below BROWSER documentLoad · 2100 ms click fetch /api/dashboard network boundary — traceparent header crosses here BACKEND GET /api/dashboard SELECT widgets GET 0 ms 2200 ms 3000 ms The backend is responsible for 120 ms of a 3-second experience — visible only when the browser is in the same trace.

Document-load spans cover navigation timing: DNS, connect, TTFB, DOM interactive, load event. They are generated from the Performance Timeline API and are the browser’s equivalent of a root span for the page view. They are not connected to a backend trace unless the server injects a trace ID into the HTML.

Interaction spans wrap a click, route change, or user gesture. They are the parent of whatever fetches the interaction triggers, and they are what makes “which user action caused this backend load” answerable.

Fetch and XHR spans are the ones that carry traceparent across the network boundary. This is the actual continuity mechanism: the browser injects the header on the outgoing request, the server extracts it, and the resulting server span becomes a child of the browser’s fetch span.

There is an important asymmetry. The browser starts the trace, so the browser makes the sampling decision — and a client you do not control is making a decision that costs you storage. Everything in the trust section below follows from that.

Step-by-Step Implementation

Step 1 — Initialize the web SDK

Keep the bundle small: register only the instrumentations you actually read.

// tracing.js — loaded after first paint, never in the critical path
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { resourceFromAttributes } from '@opentelemetry/resources';

const provider = new WebTracerProvider({
  resource: resourceFromAttributes({
    'service.name': 'dashboard-web',
    'service.version': __BUILD_SHA__,
    'deployment.environment.name': 'production',
  }),
  spanProcessors: [
    new BatchSpanProcessor(
      new OTLPTraceExporter({ url: 'https://otlp.example.com/v1/traces' }),
      // Short delay: a page can unload at any moment, taking the queue with it.
      { scheduledDelayMillis: 2000, maxQueueSize: 100 },
    ),
  ],
});

// ZoneContextManager keeps the active span across async callbacks and event
// handlers — without it, a fetch inside a click handler loses its parent.
provider.register({ contextManager: new ZoneContextManager() });

registerInstrumentations({
  instrumentations: [
    new DocumentLoadInstrumentation(),
    new FetchInstrumentation({
      // ONLY these origins receive traceparent. Everything else is untouched,
      // which avoids leaking your trace ids to third-party analytics endpoints
      // and avoids breaking their CORS policy.
      propagateTraceHeaderCorsUrls: [/^https:\/\/api\.example\.com/],
      clearTimingResources: true,
    }),
  ],
});

propagateTraceHeaderCorsUrls is the single most important option on this page. It defaults to empty, which means a freshly configured web SDK produces browser spans that never reach the backend — the trace looks broken and nothing in the console explains why.

Step 2 — Flush on unload

The default batch exporter loses whatever is queued when the tab closes, which disproportionately affects the slow page views you most want to see.

// Flush the queue with sendBeacon, which survives page unload.
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    // forceFlush returns a promise, but the browser may not wait for it —
    // the OTLP exporter falls back to navigator.sendBeacon internally when
    // the page is hidden, which is fire-and-forget and unload-safe.
    provider.forceFlush().catch(() => { /* page is going away anyway */ });
  }
});

Step 3 — Fix CORS before anything else

Adding traceparent to a cross-origin request makes it preflighted. The browser sends an OPTIONS request first, and if the response does not explicitly allow the header, the real request never leaves. This shows up as a CORS console error that mentions your API, not your tracing.

# API origin — allow the trace headers on preflight AND on the actual request
location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin      "https://app.example.com" always;
        add_header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, OPTIONS" always;
        # Without traceparent here the browser blocks the request outright.
        add_header Access-Control-Allow-Headers     "content-type, authorization, traceparent, tracestate, baggage" always;
        # Cache the preflight so you do not double every API call's latency.
        add_header Access-Control-Max-Age           86400 always;
        return 204;
    }
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    proxy_pass http://api_backend;
}

The collector endpoint needs the same treatment, since the browser posts spans to it cross-origin:

# collector-config.yaml — the OTLP HTTP receiver must accept browser origins
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - https://app.example.com
          allowed_headers: [content-type, traceparent, tracestate]
          max_age: 7200

Step 4 — Decide the trust policy for inbound client context

A public endpoint receives whatever traceparent a client chooses to send. Three policies are defensible, and the right one depends on whether the client is your own first-party app or an anonymous browser on the internet.

What to do with a traceparent sent by a browser A decision tree. From the inbound request, the first question asks whether the client is first-party and authenticated. If yes, adopt the context as the parent and keep the client sampling decision. If no, the second question asks whether trace volume from anonymous clients is bounded. If yes, record the client context as a span link and start a fresh server-side trace. If no, drop the client context entirely and start a new trace. Inbound request carries traceparent First-party client? authenticated session Adopt as parent one trace, client decides sampling Volume bounded? rate limits in place Record as span link new server trace, still correlatable Drop client context server-side root, no client link yes no yes no Whichever branch you take, never let a client's sampled flag alone decide what your backend stores.
# FastAPI — accept client context for correlation but keep sampling server-side
from opentelemetry import trace
from opentelemetry.propagate import extract
from opentelemetry.trace import Link, SpanKind

@app.middleware("http")
async def controlled_context(request, call_next):
    client_ctx = extract(dict(request.headers))
    client_span = trace.get_current_span(client_ctx)
    sc = client_span.get_span_context()

    if is_first_party(request):
        # Trusted app: continue the browser's trace directly.
        with tracer.start_as_current_span("http.server", context=client_ctx,
                                          kind=SpanKind.SERVER):
            return await call_next(request)

    # Public endpoint: start our own trace, but keep a link so the browser's
    # view and the server's view can still be joined during an investigation.
    links = [Link(sc)] if sc.is_valid else []
    with tracer.start_as_current_span("http.server", links=links,
                                      kind=SpanKind.SERVER):
        return await call_next(request)

Step 5 — Optionally seed the trace from the server

Fetch-based continuity connects a page’s API calls to the backend, but it leaves the initial document request disconnected: the server rendered the HTML in its own trace, and the browser’s document-load span belongs to a different one. For server-rendered applications where the initial render is the slow part, seeding closes that gap.

The technique is to have the server write its own span context into the HTML it returns, and have the web SDK adopt it as the parent of the document-load span:

<!-- Rendered server-side into the document head, from the active server span -->
<meta name="traceparent" content="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01">
// Adopt the server's context as the parent of the browser's document-load span
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { ROOT_CONTEXT } from '@opentelemetry/api';

const meta = document.querySelector('meta[name="traceparent"]');
const serverContext = meta
  ? new W3CTraceContextPropagator().extract(
      ROOT_CONTEXT, { traceparent: meta.content }, defaultTextMapGetter)
  : ROOT_CONTEXT;
// documentLoad spans created under serverContext now share the render's trace id.

Seeding has one significant caveat: a cached HTML document carries a stale traceparent, so every visitor served that cached page joins the same trace. Set Cache-Control: no-store on seeded documents, or strip the meta tag at the CDN, and skip seeding entirely for statically generated pages. Where the initial document is cached and the interesting work happens after hydration, fetch-based continuity alone is the better choice.

Step 6 — Verify continuity end to end

# 1. Load the page with the devtools network tab open and inspect the API call:
#    request headers should include
#      traceparent: 00-<32 hex>-<16 hex>-01
# 2. Take that trace id and query the backend:
curl -s "http://tempo:3200/api/traces/4bf92f3577b34da6a3ce929d0e0e4736" \
  | jq '[.batches[].scopeSpans[].spans[] | {name, kind}]'

# Expect spans from BOTH service.name values in one trace:
#   dashboard-web:  documentLoad, click, HTTP GET
#   dashboard-api:  GET /api/dashboard, SELECT widgets, GET cache

Edge Cases and Gotchas

  1. Third-party scripts see your trace IDs if you propagate too widely. A regex like /.*/ on propagateTraceHeaderCorsUrls sends traceparent to every analytics and ad endpoint the page touches. Keep the allow-list tight.
  2. Service workers intercept fetch and can drop headers. If a service worker reconstructs the request, headers added by instrumentation may not survive. Re-inject inside the worker, or exclude worker-handled routes from continuity expectations.
  3. beforeunload is unreliable on mobile. iOS Safari frequently skips it. visibilitychange with sendBeacon is the only combination that reliably flushes.
  4. Clock skew between browser and server is unbounded. A user’s clock can be minutes off, making child spans appear to start before their parent. Backends handle this differently — Tempo renders it as-is, some UIs clamp it. Do not compute cross-boundary durations from raw timestamps.
  5. Ad blockers block collector endpoints. Endpoints containing telemetry, analytics, or otlp in the path are commonly filtered, silently removing perhaps 15–30% of browser sessions from your data. Serve the collector from a first-party path.
  6. A hostile client can forge a trace ID. Repeating a single trace ID across millions of requests creates one gigantic trace that no UI can render. Cap accepted client context by rate limit, or use the span-link policy above.
Four things that must line up for continuity Four things that must line up for continuity. web SDK: creates spans; allow-list: header injected; CORS: preflight allows it; server: extracts context Four things that must line up for continuity web SDK creates spans allow-list header injected CORS preflight allows it server extracts context Three of the four fail silently — only the CORS step produces a visible console error.

Performance and Scale Notes

Bundle size is a real cost on the critical path. The full web SDK with document-load, fetch, XHR, and user-interaction instrumentation lands around 60 KB gzipped. Load it after first paint, and if all you need is header injection, a hand-written traceparent generator is a few hundred bytes.

Browser span volume is unbounded by design. Traffic is proportional to users, not to your capacity. A campaign that triples page views triples span volume with no warning; apply a head-based sample rate in the web SDK and enforce a rate limit at the collector.

The collector is exposed to the internet. That is a different threat model from an internal-only pipeline: require an API key or a signed token, rate-limit per IP, and keep a memory_limiter in front of everything, as described in handling collector backpressure and queue overflow.

Sample in the browser, not at the collector. A head-based decision made in the page costs one arithmetic operation and saves the entire export: unsampled sessions never serialize a span, never open a connection, and never consume a byte of a user’s mobile data allowance. Dropping the same volume at the collector saves your storage bill but has already spent the user’s battery and bandwidth. Set a modest rate — 5–10% of sessions is plenty for latency work — and raise it temporarily when investigating a specific regression.

Treat browser telemetry as a different retention class. Browser spans age out of usefulness far faster than backend spans: a page-load profile from three weeks ago tells you little once the bundle has changed twice. Routing browser traffic to a shorter-retention tenant keeps storage costs proportionate to the value of the data, and it keeps a traffic spike in the browser tier from evicting backend traces you still need.

Preflight caching matters. Without Access-Control-Max-Age, every traced API call becomes two round trips. On a high-latency mobile connection that doubles perceived API latency — an instrumentation change that makes the product measurably slower.

Troubleshooting FAQ

Why does adding traceparent break my API calls from the browser?

The header makes the request preflighted. The OPTIONS response must list traceparent (and tracestate, and baggage if used) in Access-Control-Allow-Headers, on every cross-origin route. Full walkthrough in configuring CORS for traceparent headers.

Should I trust the trace ID a browser sends?

Treat it as untrusted input: fine for correlation, never for a sampling decision, and on public endpoints prefer a span link over adopting it as the parent.

How much does the web SDK add to page weight?

Roughly 40–60 KB gzipped for a typical build. Load it lazily; a minimal hand-rolled propagator is an option when you only need continuity, not browser spans.

Why do my browser spans never arrive?

The page unloaded before the batch flushed, or the collector rejected the CORS preflight. Shorten scheduledDelayMillis, flush on visibilitychange, and check the collector’s cors.allowed_origins.

Can I correlate Core Web Vitals with backend spans?

Yes — record LCP, INP, and CLS on the document-load span, which shares its trace with the page’s API calls. See connecting Web Vitals to backend spans.


↑ Back to SDK Implementation & Context Propagation