Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 9 of 12AdvancedAug 3, 2026

Observability

Real User Monitoring architecture, error tracking with source maps, and correlating a frontend error with the backend trace that caused it.

frontend-system-designobservabilitymonitoring

Why It Matters

Backend observability is a solved discipline with mature tooling. Frontend observability is harder for a structural reason: your code runs on hardware you do not own, in a browser you did not choose, on a network you cannot measure, and it can fail without ever contacting your server.

A user on a low-end Android phone whose page took eleven seconds and then crashed generates no server-side signal at all. Your metrics look fine. Without deliberate client-side instrumentation, you are blind to the experience you are actually delivering.

Real User Monitoring

RUM measures what real users on real devices actually experienced, as opposed to synthetic monitoring, which runs a scripted check from a chosen location on a chosen device with a warm cache.

Both are useful and they answer different questions. Synthetic is reproducible, so it is a good regression alarm between deploys. RUM captures the real distribution - including the cheap phone on a congested network - which is almost always worse than the lab and is what your users live with. Optimise against RUM; alert on synthetic.

What to collect

  • Core Web Vitals - LCP, INP, CLS, gathered from the browser's own performance APIs.
  • Navigation timing - TTFB, DNS, TLS, response time. This is what lets you tell a slow server from a slow client.
  • Resource timing - which assets were slow, and which came from third parties.
  • Custom marks - the milestones that matter to your product. "Search results visible", "checkout form interactive". Generic metrics tell you the page was slow; custom marks tell you which part of the experience was.
  • Context - device, connection type, viewport, route, release version. Without this, an aggregate number is unactionable, because the aggregate hides the population that is actually suffering.

Getting the data out without becoming the problem

Monitoring that degrades performance is self-defeating, and the mechanics matter.

Diagram
100%
flowchart LR subgraph Client["Browser"] API["Performance APIs<br/>web-vitals, PerformanceObserver"] Marks["Custom marks<br/>product milestones"] Ctx["Context<br/>device, connection, route, release"] API --> Buf["In-memory buffer"] Marks --> Buf Ctx --> Buf Buf --> Sample{"Sampled in?"} Sample -->|"no"| Drop["Discard"] Sample -->|"yes"| Scrub["Scrub PII<br/>redact by key pattern"] Scrub --> Flush{"Flush trigger"} end Flush -->|"visibilitychange to hidden"| Beacon["sendBeacon /<br/>fetch keepalive"] Flush -->|"buffer full"| Beacon Flush -->|"interval elapsed"| Beacon Beacon --> Ingest["Ingestion endpoint"] Ingest --> Store[("Time-series store<br/>tagged by release")] Store --> Dash["Dashboards - p75, p95<br/>sliced by device and route"] Store --> Alert["Alerting on<br/>regression by release"] style Client fill:#0f172a,stroke:#64748b style Beacon fill:#1e3a5f,stroke:#3b82f6 style Store fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Buffer, do not send per event. One request with fifty measurements, not fifty requests.

Use sendBeacon or fetch with keepalive. A normal request is cancelled when the page unloads - which is precisely when you most need the data to leave. These are designed to survive it.

Flush on visibilitychange to hidden, not on unload. unload does not fire reliably on mobile when the OS kills a backgrounded tab, and a lot of teams lose their most interesting sessions to this.

Sample. You rarely need every session. A percentage gives you the same distribution at a fraction of the ingestion cost - though errors are usually worth sampling at a much higher rate than performance data.

Report percentiles, not averages. An average LCP of 2.1s can hide a p95 of nine seconds. The p95 is the experience of the users most likely to leave, and averages systematically hide the population you most need to see.

Error Tracking

Capturing everything

Two global handlers catch what component-level boundaries miss:

  • window.onerror / the error event - uncaught synchronous exceptions.
  • unhandledrejection - rejected promises with no .catch(). This one is frequently forgotten and, in an async-heavy codebase, is where most silent failures actually live.

Framework error boundaries sit on top of these. They let you render a recovery UI, but they do not replace the global handlers - an error in an event handler or a timer never passes through a boundary at all.

Source maps

Production JavaScript is minified, so a raw production stack trace is a.b.c is not a function at main.f8a3.js:1:48221 - which tells you nothing.

The fix is to upload source maps to your error tracking service at build time, tagged with the same release identifier you stamp on the client bundle. The service de-minifies incoming traces server-side by matching release to map.

Two things go wrong reliably:

Publicly served source maps are source disclosure. They contain your original code. Upload them to the tracker and remove them from the deployed output - do not ship them to a public URL and call it done.

Release identifier mismatch. If the map is uploaded under a different version than the client reports, every trace stays minified while the upload appears to have succeeded. Deriving the release from a single source - the commit SHA - and using it in both places is the fix.

Logging from Client to Backend

What must never be logged

Client-side telemetry leaves your infrastructure and lands in a third-party system with its own retention policy and access model. That raises the stakes on what goes into it.

Never send: authentication tokens, passwords, payment details, or personal information - email addresses, names, precise location, anything that identifies a person.

This is harder than it sounds because the leaks are almost never deliberate:

  • A form object logged wholesale on submit contains the password field.
  • A URL logged for context contains a reset token in the query string.
  • An error object serialised in full contains the request body.
  • A session replay tool captures whatever the user typed into an unmasked field.

The architectural answer is a scrubbing layer inside the reporting client that redacts by key pattern - password, token, authorization, card, secret, email - before anything is queued. Redaction cannot be left to whoever writes the individual log line, because eventually one of them will not.

Structured, not string-formatted

Log objects, not sentences. logger.warn({ event: 'checkout_retry', attempt: 2, reason: 'timeout' }) is queryable and aggregatable. logger.warn('Retrying checkout, attempt 2 because timeout') requires someone to write a regex later.

Keep a consistent envelope on every entry: level, event name, release, route, session ID, correlation ID, timestamp.

Correlation IDs

The payoff. A user reports "checkout failed" - and you need to get from that to the failing database query.

Diagram
100%
sequenceDiagram participant U as User participant C as Client participant BFF as BFF / API participant S as Payment service participant O as Observability U->>C: Clicks "Pay" C->>C: Generate correlationId = req_9f2a C->>BFF: POST /checkout<br/>X-Correlation-ID: req_9f2a BFF->>BFF: Attach req_9f2a to every log + span BFF->>S: charge()<br/>X-Correlation-ID: req_9f2a S->>S: DB timeout - logs with req_9f2a S-->>BFF: 500 BFF-->>C: 500 C->>O: Error report<br/>correlationId: req_9f2a<br/>sessionId, release, route BFF->>O: Server logs + trace, req_9f2a S->>O: Service logs, req_9f2a Note over O: One ID joins the user's session,<br/>the frontend error, and the failing query
visualized byIOCombats

The client generates an ID per request, sends it in a header, and attaches the same ID to any error it reports. The backend stamps it on every log line and span it produces for that request, and propagates it through every internal hop.

Two details decide whether this works: the ID must be per request, not per session, or you cannot distinguish which of forty calls failed. And every service must propagate the header - one service that drops it ends the trace there, which is the most common reason a correlation setup silently underdelivers.

Tradeoffs

OptionProsConsWhen to Use
RUMMeasures the real user distribution, catches device and network effects a lab never seesNoisy, needs sampling, costs ingestion, no reproducible baselineAny product where real-world performance matters
Synthetic monitoringReproducible, isolates regressions between deploys, works pre-launchMeasures one artificial environment, systematically optimisticRegression alarms and pre-production gates
Full session captureComplete picture, every session available for investigationExpensive at volume, heavier client, larger privacy surfaceLow-traffic products, or short debugging windows
Sampled telemetrySame distribution at a fraction of the cost, lighter on the clientRare issues may never be capturedDefault at any real traffic volume
Source maps uploaded to the trackerReadable production traces, source stays privateNeeds matching release IDs, silently fails when they driftAlways
Publicly served source mapsTrivial to set upDiscloses your source code to anyoneNever
Correlation IDsOne ID joins client error to backend trace, turns reports into root causesEvery service must propagate the header, needs cross-team agreementAnything with more than one service behind it

Where This Applies

The metrics collected here are the ones defined in Performance Engineering - that article explains what LCP, INP, and CLS mean; this one covers measuring them on real users. The rule against logging tokens depends directly on the storage decision made in Security Architecture, and CSP violation reports are another telemetry stream flowing into this same pipeline. Correlation IDs cross the boundary described in Application Architecture at Scale, and a BFF is usually where they are first stamped server-side.

In the applied practice problems, this underpins Video Player, where startup time and stall duration exist nowhere except the client, and File Upload System, where partial failures need enough context to resume rather than restart. The permission funnel in Notification System is another metric with no server-side equivalent.

Advertisement

Frequently Asked Questions

Why does synthetic monitoring disagree with your Real User Monitoring numbers, and which should you trust?

Because they measure different populations. Synthetic runs a scripted check from a chosen location on a chosen device with a warm cache and a stable network - it is reproducible and therefore good for catching regressions between deploys. RUM measures the actual distribution of your users, including cheap Android devices on congested mobile networks, cold caches, and browser extensions injecting scripts. That distribution is almost always worse than the lab, and it is what your users experience, so RUM is what you optimise against. Synthetic is a regression alarm, not a truth source.

How do you send RUM data without the monitoring itself hurting performance?

Three rules. Buffer rather than sending per event, so you make one request instead of fifty. Send with sendBeacon or fetch with keepalive rather than a normal request, because a normal request is cancelled when the page unloads - which is exactly when you most need the data to leave. And flush on visibilitychange to hidden rather than on unload, because unload does not fire reliably on mobile when the OS kills a backgrounded tab. On top of that, sample - you almost never need every session, and a percentage of traffic gives you the same distribution at a fraction of the cost.

Your production stack traces are unreadable minified nonsense. Explain the fix and its main risk.

Upload source maps to the error tracking service at build time, tagged with the same release identifier you stamp on the client bundle. The service then de-minifies incoming stack traces server-side by matching release to map. The main risk is that source maps expose your original source code, so they must not be publicly served - upload them to the tracker and delete them from the deployed output, or the fix becomes a source disclosure. The second common failure is a release identifier mismatch, where the map is uploaded under a different version than the client reports, and every trace stays minified while the upload appears to have worked.

How do you connect a frontend error to the backend request that caused it?

Generate a correlation ID on the client for each request, send it in a header, and have the backend attach it to every log line and span it produces for that request. The client attaches the same ID to any error it reports. Now one identifier links the user's session, the frontend error, and the full server-side trace, so you can move from a user report straight to the failing query. The important detail is that the ID must be generated per request rather than per session, and it must propagate through every internal service hop - if a service drops the header, the trace ends there.

What should never be logged client-side, and why is this harder than it sounds?

Never log tokens, passwords, payment details, or personal information like email addresses, names, and precise location - client-side telemetry leaves your infrastructure and lands in a third-party system with its own retention and access model. It is harder than it sounds because the leaks are almost never deliberate. A full form object logged on submit contains the password field. A URL logged for context contains a reset token in the query string. An error object serialised wholesale contains the request body. A DOM snapshot from a session replay tool captures whatever the user typed. The architectural answer is a scrubbing layer in the reporting client that redacts by key pattern before anything is sent, so redaction is not left to whoever writes the log line.