Correlating Errors with Request IDs Across Services
When a user reports “the checkout page broke,” you have a browser error in one log stream and a backend 500 in another, and no reliable way to prove they belong to the same failed request. This how-to shows how to mint a single request ID in the browser, carry it over the wire on every hop, and stamp it onto every captured error so the two halves join with one query. It applies the correlation ideas from the parent guide Structured Error Logging & Breadcrumbs to the specific problem of cross-service tracing, and sits under the broader Core JavaScript Error Handling & Boundaries reference. If you also export spans, the same identifier can double as a trace tag, which pairs well with Instrumenting Browser Errors with OpenTelemetry Web.
Symptom / Trigger
The tell-tale sign is two error records that clearly describe the same incident but share no common field. The browser logs a failed fetch, the API logs an unhandled exception a few milliseconds later, and nothing links them:
// Browser console — a request failed, but with what ID?
POST /api/checkout 500 (Internal Server Error)
Uncaught (in promise) Error: checkout failed
at submitOrder (checkout.js:42:11)
// Server log, a different stream, no shared key:
{"level":"error","msg":"charge failed","route":"/api/checkout","stack":"TypeError: cannot read 'total' of undefined\n at charge (billing.js:88)"}
You can see both records. What you cannot do is prove the browser Error and the server TypeError came from the same HTTP request, because neither carries an identifier the other side also recorded. At low traffic you guess by timestamp; at scale, dozens of /api/checkout calls land in the same second and the guess becomes noise. The problem compounds with every hop: once the API forwards the call to a billing service and a fraud check, you now have four log streams describing one user action, and not one of them names the others. On-call engineers end up scrolling three dashboards side by side, eyeballing millisecond timestamps, and hoping no two requests overlapped — a workflow that fails exactly when you need it most, during an incident under load.
Root Cause Explanation
An HTTP request is stateless: the browser and the server each generate their own view of a failure and neither is aware of the other’s log line. Correlation requires a shared value that both sides record. The broken pattern is letting each side invent its own identifier independently, so no two records ever match:
// Broken — the client and server each mint a different id
// client:
const clientId = crypto.randomUUID(); // e.g. "aaa-111"
await fetch('/api/checkout', { method: 'POST', body });
// server, unaware of clientId:
const serverId = crypto.randomUUID(); // e.g. "zzz-999" — never joins
Two IDs that never meet are worse than none, because they look like join keys and lull you into trusting them. The fix rests on one rule: the identifier is created exactly once, at the true entry point of the request, and every other participant adopts the value it receives rather than inventing a fresh one. Minting is a privilege reserved for the first process that sees a request with no ID attached; everyone downstream is a consumer. Get that rule right and a single value threads the browser, the API gateway, and every internal service, so a query for one ID returns the complete story. Get it wrong — let any middle service call randomUUID() unconditionally — and the chain silently breaks at that hop, leaving you a partial trace that looks complete. The pipeline below shows that single minted value flowing untouched across the boundary.
Step-by-Step Fix
1. Mint one request ID in the browser and reuse it per interaction
Generate a fresh ID for each user-initiated action so retries and parallel calls stay distinct, and keep it in a small holder you can read at capture time.
// request-id.js (browser) — a fresh id per logical action
export function newRequestId() {
return crypto.randomUUID(); // collision-free without coordination
}
// Hold the id for the action currently in flight so error capture can read it
export let activeRequestId = null;
export function withRequestId(id, fn) {
activeRequestId = id; // set before the call
return Promise.resolve(fn()).finally(() => { activeRequestId = null; });
}
2. Inject the ID as a header on every outbound request
Wrap fetch so the header is attached uniformly and you never rely on each call site remembering to add it.
// fetch-with-id.js — one wrapper, header added everywhere
import { newRequestId, withRequestId } from './request-id.js';
export function tracedFetch(input, init = {}) {
const id = newRequestId();
const headers = { ...init.headers, 'X-Request-Id': id }; // travels to the server
return withRequestId(id, () => fetch(input, { ...init, headers }));
}
3. Adopt or mint the ID in server middleware and bind it to async context
On the server, prefer the inbound header and fall back to a new ID only at the true entry point; bind it with AsyncLocalStorage so every downstream await can read it.
// server-context.js — adopt inbound id, expose it everywhere downstream
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export const store = new AsyncLocalStorage();
export function requestIdMiddleware(req, res, next) {
const id = req.headers['x-request-id'] || randomUUID(); // adopt, else mint
res.setHeader('X-Request-Id', id); // echo back so the client agrees
store.run({ requestId: id }, next); // all async work inherits the id
}
export const currentRequestId = () => store.getStore()?.requestId ?? null;
The middleware does three things in order that each matter: it adopts the inbound value so a chain that started in the browser is never broken here, it echoes the ID back on the response so the client can stamp its own error with the identical string, and it opens an AsyncLocalStorage scope so no deeper function needs the ID passed as an argument. Register this middleware before any route that can throw, because a handler that runs outside the store.run scope reads null and produces an orphan record.
4. Tag every captured error with the current request ID
At every capture point — a catch block, a global handler, an error boundary — read the current ID and attach it to the record so no error ships without a join key.
// capture.js (server) — stamp the id onto the record
import { currentRequestId } from './server-context.js';
export function captureError(error, context = {}) {
const record = {
ts: new Date().toISOString(),
requestId: currentRequestId(), // the shared join key, read from async context
name: error?.name ?? 'Error',
message: error?.message ?? String(error),
stack: error?.stack ?? null,
context,
};
process.stdout.write(JSON.stringify(record) + '\n'); // one JSON line per error
return record;
}
5. Read the echoed ID on the client so the browser error carries it too
Because the server echoes X-Request-Id, the browser can stamp the exact same value onto its own error record, closing the loop.
// client-capture.js — join the browser throw to the server record
import { activeRequestId } from './request-id.js';
export async function submitOrder(body) {
const res = await tracedFetch('/api/checkout', { method: 'POST', body });
if (!res.ok) {
const id = res.headers.get('X-Request-Id') ?? activeRequestId; // same key both sides
reportBrowserError(new Error('checkout failed'), { requestId: id });
}
return res;
}
Notice that the client reads the ID from the response header first and only falls back to the value it held locally. Reading the echoed header is the more trustworthy source, because it confirms the server actually adopted the ID rather than silently minting its own; if the two ever disagree, that mismatch is itself a useful signal that a proxy rewrote the header somewhere in between. With one identifier flowing across the boundary and recorded identically on both ends, the fractured before-state collapses into a single searchable timeline.
Verification
Fire a request that fails on purpose and assert both the browser record and the server record carry the identical ID. The join is real only if the two strings match exactly.
// verify.test.js — run with: node --test
import test from 'node:test';
import assert from 'node:assert';
import { store, currentRequestId } from './server-context.js';
test('server adopts the inbound X-Request-Id instead of minting a new one', () => {
const inbound = 'req-abc-123';
store.run({ requestId: inbound }, () => {
// simulate a downstream async catch reading the bound id
const record = { requestId: currentRequestId() };
assert.equal(record.requestId, inbound); // must equal the client's id, not a fresh one
});
});
In an end-to-end check, trigger the failing action in the browser, copy the ID printed in the console, and run one query against your log platform — requestId:"req-abc-123" should return both the frontend throw and the backend exception as a single group. The stages that make that query possible are shown below.
Edge Cases & Gotchas
- Trust the inbound header only at the edge. A public gateway should overwrite or validate a client-supplied
X-Request-Id, because an attacker can send a fixed value to collide records. Adopt inbound IDs freely between your own internal services, but sanitize at the boundary. - The ID is
nullinside detached callbacks. Work scheduled withsetTimeout, a bare event listener, or a worker escapes theAsyncLocalStorage.runscope. Capture the ID into a local variable before the boundary, or re-enter the store inside the callback. - Header casing and proxies vary. Some infrastructure normalizes to
X-Request-ID, others forwardtraceparentinstead. Read the header case-insensitively and, if you already run distributed tracing, reuse the trace ID as your request ID so you keep a single identifier across logs and spans. See How to Log Custom Error Properties Without Blooming Payloads for keeping that extra field lean. - Fan-out needs a parent plus a child. When one request triggers several downstream calls, keep the original request ID as a shared parent and add a per-call span ID; correlate on the parent and disambiguate on the child.
FAQ
Should the request ID be a UUID or can I reuse the trace ID?
Either works, and reusing an existing trace ID is often better. If you already emit OpenTelemetry spans, the trace_id is a natural request ID: one value then joins your logs, your traces, and your error records, and you avoid maintaining a second parallel identifier. If you have no tracing yet, crypto.randomUUID() is the safe default because it is collision-free without any coordination between services and is available in every modern browser and in Node.js 16.7+.
What if a request passes through a service that strips custom headers?
Put the ID on a header your infrastructure is known to preserve, or on the standard traceparent header that most proxies and meshes forward by design. If a hop still drops it, propagate the ID in the message body or job payload for that segment and re-attach it as a header on the far side. The goal is an unbroken chain; the transport can change per hop as long as the value survives.
Do I need AsyncLocalStorage, or can I just pass the ID as an argument?
You can thread it through arguments, but it is brittle: every function on the path, including third-party middleware you do not control, must accept and forward it, and one missing hop breaks the chain. AsyncLocalStorage binds the ID to the request’s async execution once, so any catch block or logger anywhere in the call tree reads it with currentRequestId() and no plumbing. Reserve explicit argument passing for hot paths where you have measured the store’s overhead and found it unacceptable.
Related
- Structured Error Logging & Breadcrumbs — the parent guide this how-to extends, covering the full JSON envelope these IDs live in
- Core JavaScript Error Handling & Boundaries — the section reference for every capture surface a tagged error flows from
- Instrumenting Browser Errors with OpenTelemetry Web — reuse the trace ID as your request ID and export it on spans
- How to Log Custom Error Properties Without Blooming Payloads — keep the extra correlation fields small so records stay cheap to index