Logging in the browser
Sending small, structured records of what happened in each user's browser to your own servers, in batches that survive the tab closing, sampled and scrubbed so they stay cheap and safe: the only way to see bugs that never happen on your machine.
Why the browser needs its own logs
Picture a food delivery app on a Friday evening. Support gets 40 messages in an hour: "I pressed Pay and nothing happened." The backend dashboards are green, and the payments service shows no errors or slow requests. The on-call engineer tries the checkout on a laptop and it works every time.
The bug is real, but it lives in the browser. A new version of the checkout script throws an error on one older Safari version when a saved card has no expiry date. The click handler dies before any request is sent. The server never hears about it, so the server logs can't show it.
Frontend logging is how you see this. The page records small facts about what happened ("checkout opened", "pay clicked", "card form error") and sends them to a server you control. With those records the engineer filters by event = pay_clicked without a following payment_requested, groups by browser, and finds the Safari pattern in minutes instead of days.
Why it is harder than server logging
On a server, logging is almost free. You write a line to standard output, an agent ships it, and you trust every line because your own code on your own machine wrote it. In the browser, every one of those assumptions breaks.
- Millions of machines you don't own. Your code runs on phones, old laptops, smart TVs, and in-app browsers, each with its own browser version, extensions, memory, and network. A bug can live on 0.3% of devices and still hit thousands of people a day.
- Every log costs the user. Each record costs bytes on someone's data plan and work on their CPU. One request per event drains battery and competes with the requests the user actually wants.
- The network is unreliable. The user goes into a tunnel, the request fails, and the log is gone unless you kept it. You can't retry forever either, because the page might close first.
- Pages die without warning. The user closes the tab, swipes the app away, or the phone kills a background tab to save memory. Anything still in memory is lost, and the most interesting logs (right before someone gives up) are the most likely to go.
- Ad blockers and privacy tools. Many blockers drop requests to known analytics and logging domains, and some drop any URL with words like
log,track, oranalyticsin it. A share of your users will send you nothing at all. - The data is untrusted. Anyone can send fake logs to your endpoint, or millions of them: it is a public API.
- Privacy law applies. The browser is where personal data lives: names in forms, emails in URLs, tokens in storage. A careless log line copies it into a system that was never meant to hold it.
What browser logs are for
Frontend logs answer questions the server can't: did the click reach a handler, at which step did the user stop, what was the app state before the error, which feature flag variant were they in, and how long did the client-side work take?
Logging is one of several related tools. Error tracking captures crashes with stack traces and groups them (the next lesson). Real-user monitoring, or RUM, measures speed in real browsers, such as the Core Web Vitals. Analytics counts what users do, for product decisions.
Logging is the general pipe underneath: small structured events with context, sent home reliably. Many teams use one pipeline for all four, with a field that says what kind of event each record is.
| Tool | Main question | Typical volume | Who reads it |
|---|---|---|---|
| Logging | What happened in this session, step by step? | Medium, sampled | Engineers debugging |
| Error tracking | What crashed, where, for how many users? | Low, every error | Engineers on call |
| Real-user monitoring | How fast is the app for real users? | Medium, sampled | Performance team |
| Analytics | What do users do, and does a change help? | High, often every event | Product team |
Check your understanding · Exercise 1
A team logs every click with one fetch per event and finds their logs show far fewer checkout abandons than their analytics tool. Which causes are likely? Select all that apply.
The mental model: a pipeline with a small suitcase
Think of each browser as a traveller with a small suitcase. Through the day the traveller drops notes into the suitcase. Every so often, and always before leaving a hotel, the traveller posts the whole suitcase home in one parcel. The parcel has a weight limit, and some notes are thrown away before packing because the post is expensive and nobody needs every note.
That is the whole design. A log call doesn't send anything; it adds a record to a small in-memory buffer. A separate flusher empties the buffer in one request when the buffer is full, when a timer fires, or when the page is about to go away. Everything else is a filter on the way in.
The stages, in order
- Emit. App code calls something like
log.info("checkout_opened", { items: 3 }). It must be cheap and never throw, so logging can't break the feature it watches. - Enrich. The logger adds the context every record needs: timestamp, session ID, page, release version, browser, and the current trace ID if a request is in flight.
- Scrub. Remove or mask anything personal: emails, tokens, card numbers, query strings. It happens in the browser, before the data leaves the device.
- Sample. Decide whether to keep the record. Errors are usually kept; debug chatter is mostly dropped. The decision is often made once per session.
- Buffer. Push the record into an array in memory, with a cap on how many records and bytes it can hold.
- Flush. Send the buffer as one request: on a size threshold, on a timer, and when the page becomes hidden.
- Ingest. A small public endpoint checks, rate-limits, and stores the batch, then hands it to the log store where engineers search it.
The order matters. Enriching before scrubbing means the scrubber also checks the URL and other context fields, not just what the app passed in. Scrubbing and sampling before the buffer mean personal data and dropped records never sit in memory waiting to be sent.
Two rules that shape every choice
First: logging must never hurt the user. No blocking the main thread, no unbounded memory, no extra request per click, no crash when the endpoint is down. If you must choose between losing a log and slowing the page, lose the log.
Second: logs are a sample, not a ledger. Some records will be blocked, some lost when a tab is killed, some dropped by sampling. That is fine for debugging and trends. If something must be counted exactly (orders, payments, ad billing), count it on the server, where the action actually happens.
Check your understanding · Exercise 2
Why scrub personal data in the browser instead of at the ingest endpoint? Select all that apply.
Structured logs and a schema
A log line like "User clicked pay but card was invalid" reads well once. Multiply it by 2 million sessions and it is useless: you can't count it, filter it by browser, or join it with anything. Every developer phrases it a little differently, and the message changes whenever someone edits the code.
A structured log is a record with named fields, usually JSON. The message is a stable event name, and everything that varies goes into its own field. Your log store indexes the fields, so a query like "event = card_invalid by browser in the last hour" takes a second.
{
"ts": "2026-10-16T18:42:07.315Z",
"level": "warn",
"event": "card_form_invalid",
"kind": "log",
"attrs": { "reason": "missing_expiry", "savedCard": true },
"session": "s_7f3a9c21",
"page": "/checkout",
"release": "web@2026.10.16-3",
"ua": { "browser": "Safari", "version": "15.6", "mobile": true },
"trace": "4bf92f3577b34da6a3ce929d0e0e4736",
"sampleRate": 1,
"seq": 41
}
event name is what you count and alert on; the trace is what you join on. Everything else is context you filter by.The fields every record needs
ts: the time on the client, in UTC ISO format. Client clocks drift, sometimes by minutes, so the server also stamps areceivedAt. Useseqwhen order matters.level: how serious it is (next chapter).event: a stable, lowercase name such ascheckout_opened, from a short list. Renaming it breaks every dashboard that uses it.attrs: event-specific details, flat and small. Numbers as numbers, booleans as booleans, short strings from a known set. No free text from users.session: a random ID for this visit, so you can replay one user's story in order. It is not a user ID and doesn't say who the person is.release: the exact build. It is the first field you filter on after a deploy ("did this start with version-3?").page: the route pattern (/orders/:id), not the full URL, which can hold IDs and personal data.trace: the trace ID of the related network request, if any, to join with backend logs (chapter 8).seq: a counter per session, so you can spot gaps where records were lost.
Treat the schema as a contract
Write the schema down: a TypeScript type in the app and a JSON Schema or similar check at the endpoint. Keep a registry of event names with an owner and a short meaning for each. Reject or quarantine records that don't match, instead of storing junk that costs money and breaks queries.
Keep field count and cardinality low. Cardinality is how many distinct values a field has. browser has a handful; orderId has millions. Log stores and metrics systems often charge by indexed fields and distinct values, so a high-cardinality field you index "just in case" can raise the bill sharply.
Check your understanding · Exercise 3
Which of these make good attrs on a search_submitted event? Select all that apply.
Levels, and what to log
A log level says how much a record matters. It decides three things: whether the record is created at all in production, how heavily it is sampled, and whether it can wake someone up. Most loggers use the five levels below, and console has methods for the first four.
| Level | Means | Example | In production |
|---|---|---|---|
debug | Detail for a developer tracing a flow | cart_state_recomputed with item counts | Off, or on for a few opted-in sessions |
info | A normal step worth knowing about | checkout_opened, payment_requested | Sampled, for example 10% of sessions |
warn | Something unexpected the app recovered from | retry_succeeded after a 503, image_fallback_used | Kept at a higher rate, often 100% |
error | A user-visible failure | payment_failed, a caught exception | Always kept; also sent to error tracking |
fatal | The app can't continue | a blank screen after a chunk failed to load | Always kept; can page on-call |
What is worth logging
Log decisions and boundaries, not every line. The most useful frontend records sit where the app talks to something it doesn't control: the network, storage, third-party scripts, the user, and the browser itself.
- Steps of key flows: sign-up, checkout, upload, publish. Start, each step, success, and failure with a reason, so you can find where users get stuck.
- Network outcomes the app handled: a retry, a timeout, a fallback to cached data, an offline queue replay. Only the client knows it gave up and showed stale data. The retries and backoff lesson covers which retries to log.
- State that explains errors: feature flag variants, which tab or panel was open, how many items were in the list, whether the user was offline.
- Recoveries: an error boundary that showed a fallback, a chunk that failed and was reloaded, storage that was full. These are warnings today and incidents tomorrow.
- Slow client work: a long render or a long task over a budget, with the component name.
What not to log
- Anything a user typed, unless it's from a fixed set (a dropdown value) or reduced to a length or a category.
- Tokens, cookies, auth headers, full URLs with query strings, and full request or response bodies.
- Every render, every mouse move, every scroll. Use counts and summaries ("scrolled 80% of the page") if you need them.
- The same error 500 times in a loop. Rate-limit repeated records in the logger: after 5 identical records in a session, count the rest and send one summary.
A common mistake is shipping console.log calls and calling it logging. console output stays on the user's machine and nobody ever sees it. Worse, an object logged to console can be kept in memory while devtools are open. Use a logger that writes to console in development and to the buffer in production, and strip debug calls from the production bundle with a build flag.
Check your understanding · Exercise 4
A photo upload fails, the app retries twice, and the third try works. What should it log? Pick one.
Batching, and sending before the page goes away
Sending one request per record is the most common mistake. A busy page can produce 50 records a minute. As separate requests, that is 50 sets of headers (several hundred bytes each before compression, more with cookies), 50 chances to wake the phone's radio, and 50 requests competing with the API calls the user is waiting for. As one batch, it is one request of a few kilobytes.
Three flush triggers
- Size: flush when the buffer holds N records (often 20 to 50) or about 16 to 32 KB. This keeps each request small and well under the browser's limits.
- Time: flush every few seconds (often 5 to 15 s) if anything is waiting, so a quiet page doesn't hold records for minutes.
- Page hidden: flush immediately when the page stops being visible. This is the one most people get wrong, and the one that matters most.
When does a page go away?
Older code flushed in the unload or beforeunload event, which is unreliable. On phones these events often don't fire: the user switches app, and later the system kills the tab without running more code. Chrome finished a gradual rollout in version 155 (October 2026) after which unload handlers don't run unless the page opts back in with a Permissions-Policy header. Where unload still runs, as in Firefox, a handler usually keeps the page out of the back/forward cache, so Back gets slower.
The reliable signal is visibilitychange with document.visibilityState === "hidden". It fires when the user switches tab, minimizes the window, switches app on a phone, locks the screen, or starts to close or leave the page. After it, the page may be frozen or discarded with no further events, so treat "hidden" as possibly your last moment. Also listen to pagehide as a backup for older browsers that missed visibilitychange on some closes.
visibilitychange handler.sendBeacon and fetch keepalive
A normal fetch started while the page is closing is usually cancelled with the document. Two APIs exist to hand a request to the browser so it finishes even after the page is gone.
navigator.sendBeacon(url, data)queues a POST and returns at once withtrueif the browser accepted it,falseif not (for example, the body is too large or the keepalive budget is full). You can't set headers or read the response. A string body is sent astext/plain, a simple request, so a cross-origin endpoint needs no CORS preflight.fetch(url, { method: "POST", body, keepalive: true })does the same job with the fullfetchAPI: custom headers, other methods, a promise. Custom headers on a cross-origin URL trigger a preflight, which can be lost if the page dies.
Both share one budget: the bodies of all keepalive requests in flight at the same time can total at most 64 KiB. The Fetch standard sets this limit per page and Chrome enforces it; other browsers count slightly differently. A request over the budget is refused (sendBeacon returns false; fetch rejects). So keep the batch sent on hide small, which is another reason to flush on size and time during the session.
const MAX_BATCH = 30;
let buffer: LogRecord[] = [];
export function log(rec: LogRecord) {
buffer.push(rec);
if (buffer.length >= MAX_BATCH) flush();
}
function flush() {
if (buffer.length === 0) return;
const body = JSON.stringify({ sentAt: Date.now(), records: buffer });
buffer = [];
const queued = navigator.sendBeacon("/api/i/batch", body);
if (!queued) saveForNextVisit(body); // over budget or refused
}
setInterval(flush, 10_000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
window.addEventListener("pagehide", flush);
saveForNextVisit writes the refused batch to localStorage or IndexedDB, with a cap, and the next page load sends it first. That turns "lost" into "late", which is fine for logs. Keep the cap small (say 100 KB) and drop the oldest batches first; the lesson on client storage covers the quotas. Clear the stored batch only after a later send is accepted.
Try the trade-offs in the lab. Start with no batching and no hide flush on a phone, then add a batch size, a timer, and a visibilitychange flush, and watch requests, bytes, and lost events change. Then turn the timer off, pick "no limit" and a very busy session, and flush on visibilitychange to see the 64 KiB budget refuse the final beacon.
Nothing was left in the buffer when the visit ended. Records waited 0.0 s on average before being sent. For 1,000 visits like this with 100% of sessions logged: 1000 sessions send 68,000 requests and 61.0 MB.
Decision How should the logger send batches?
What we pick: For most apps, use fetch while the page is visible and a beacon in the hide handler. With a vendor SDK, keep its transport but send through your own domain. Plain sendBeacon everywhere suits only tiny scripts.
Check your understanding · Exercise 5
A user on Android switches from your app to WhatsApp and never comes back. The system kills the tab 10 minutes later. Which handlers ran? Pick one.
Sampling: keeping a few to learn about all
Suppose an app has 2 million sessions a day and each session sends 60 records of about 300 bytes. That is 120 million records and 36 GB of raw logs per day, before indexes and copies. Most of it says "everything went fine". Sampling keeps a representative share and drops the rest, so you pay for 5% or 10% and still see every pattern that affects more than a few hundred users.
Head sampling and tail sampling
Head sampling decides at the start, before anything happens: "this session is in the 10%". It is cheap, because dropped sessions send nothing. Its weakness is that it decides before it knows whether the session will be interesting. A rare bug in an unsampled session leaves no trace.
Tail sampling decides at the end, after seeing what happened: "keep every session that had an error, and 5% of the rest". It catches the interesting sessions, but someone has to hold everything until the decision, which on a server is a collector with memory.
In a browser, the client itself can act as the buffer: keep the last few dozen info and debug records in a ring buffer, and send them only if an error happens. Error trackers call these records breadcrumbs.
Whole sessions or nothing: every kept session tells a full story, but errors in dropped sessions are invisible.
Sample per session, not per record
If each record is kept at random with 10% chance, every session is full of holes. You see pay_clicked but not the card_form_invalid before it, and you can't follow any story. Decide once per session instead, and keep all of that session's records or none.
Make the decision deterministic: hash the session ID into a number from 0 to 9,999 and keep the session if the number is below 1,000. Every tab and every record of the same session then makes the same choice, and the backend can repeat it too. Store the rate on each record (sampleRate: 0.1) so the dashboards can scale counts back up: 1,200 sampled checkout_opened records at 10% means about 12,000 real ones.
function inSample(sessionId: string, rate: number): boolean {
let h = 2166136261; // FNV-1a, 32-bit
for (let i = 0; i < sessionId.length; i++) {
h ^= sessionId.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 10_000 < rate * 10_000;
}
const RATES = { debug: 0, info: 0.1, warn: 1, error: 1, fatal: 1 };
Decision How should a browser logger sample?
What we pick: Combine them: keep all errors, head-sample info by session, drop debug, and attach a small ring buffer to every error. Raise the rate for a new release or flagged feature for the first hours, then lower it.
Check your understanding · Exercise 6
Dashboards show 300 payment_failed errors yesterday. Errors are kept at 100% and info at 10%. A teammate multiplies by 10 and reports 3,000. What is wrong? Select all that apply.
Keeping personal data out: scrubbing and consent
PII, personally identifiable information, is any data that points to a person: a name, an email, a phone number, an address, an IP address, a precise location, a government ID, and often a user ID too. Laws such as the EU's GDPR and California's CCPA limit how you collect, keep, and share it, and give people the right to have it deleted.
A log store is the worst place for it: it is copied widely, kept for weeks, searched by many engineers, and hard to delete from one record at a time.
How personal data sneaks into logs
- URLs.
/reset-password?token=...&email=jane%40example.com. Logginglocation.hrefcopies both. Search pages put the query in the URL too. - Error messages. A validation library throws
Invalid email: jane@example.com. The message is logged as-is. - Whole objects.
log.info("profile_saved", { user })sends the entire user object, address included. - Request and response bodies captured "for debugging", which carry everything the API carries.
- DOM text. Session replay tools and click loggers that capture the text of the clicked element pick up names and messages.
Allowlist first, patterns second
There are two ways to scrub. A denylist removes fields or patterns you know are bad: keys named password, token, email; strings that look like emails or card numbers. An allowlist keeps only fields you have approved and drops everything else. Denylists always miss something new (a field called contact, a token format you didn't expect), while allowlists fail safe: an unknown field is dropped, not leaked.
- Allowlist the shape. Only fields in the schema survive. Unknown keys in
attrsare dropped and counted. - Reduce URLs. Log the route pattern (
/orders/:id) and drop the query string and hash, or keep only allowlisted parameters such asutm_source. - Mask by pattern. As a second net, replace anything that looks like an email, card number, phone number, or long token with
[email],[card],[token]. - Cap lengths. Cut every string at, say, 256 characters. Long strings are where pasted data hides, and they cost money.
- Scrub again on the server, because an old client or a hand-made request can skip yours.
Try each step alone: masking catches the email in two places, but only the allowlist removes the user object, and only reducing the URL removes the whole query.
Consent and identifiers
Debugging logs with no personal data that are needed to run the service are usually treated differently from analytics or ad tracking, but the rules depend on the country and your legal team. A safe default is to respect the user's consent choices in the logger itself. Before consent, send only strictly necessary error records with a random session ID; after consent for analytics, add product events. Never set a long-lived identifier cookie just for logging.
To connect logs to a user for support, log a pseudonymous ID instead of the email: a random ID the backend can map to the account, kept in a separate, access-controlled table. Deleting the mapping cuts the link between old logs and the person without touching the logs. Also set a short retention, often 14 to 30 days for debug logs, because data you no longer have can't leak.
Check your understanding · Exercise 7
Which scrubbing steps protect against a field nobody has thought of yet? Select all that apply.
Correlation IDs: one story across browser and server
A browser record says payment_failed at 18:42:07. The payments service logged 4,000 requests that second; which one was this user's? Without a shared key, you guess by time. A correlation ID is that key: an ID created for one request, sent with it, and written into the logs on both sides.
Three IDs, three scopes
| ID | Created by | Lives for | Joins |
|---|---|---|---|
| Session ID | The browser, on first load | One visit (or 30 minutes idle) | All records from one user's visit, in order |
| Trace ID | The browser, per user action or request | One request and everything it causes on the backend | A browser record with the server spans that served it |
| Request ID | The edge or server, per request | One HTTP request | Load balancer, gateway, and service logs for that hop |
The W3C traceparent header
You could invent an X-Request-Id header, and many teams did. Today the standard is W3C Trace Context: a traceparent header that tracing tools such as OpenTelemetry, Datadog, and others understand. It has four parts separated by dashes.
trace-id is the part you log on both sides and search for. The flags carry the sampling decision, so the backend knows whether to keep the whole trace.- version:
00, the only version so far. - trace-id: 32 hex characters (16 random bytes) shared by every span of the whole operation. Not all zeros.
- parent-id: 16 hex characters (8 bytes), the ID of the caller's span. From the browser, it identifies the browser's span for this request.
- trace-flags: 2 hex characters;
01means "this trace is sampled, please record it",00means not sampled.
The browser makes a new trace ID per user action ("pay clicked"), sets traceparent on the fetch, and writes the same trace ID into its own log records. The backend tracing library reads the header, continues the trace, and stamps the ID on every log line and span. Now a single search for the trace ID shows the click, the API gateway, the payment service, the database call, and the error, in one timeline.
The sampled flag connects to the previous chapter: if the browser session is in the sample, it sends 01, and the backend keeps the full trace. Other sessions send 00, and the backend can still decide to keep a trace that ends in an error (tail sampling on the server side).
Cross-origin requests, and getting the ID back
traceparent is not a CORS-safelisted header. On a cross-origin API (app.example.com calling api.example.com), adding it makes the browser send a preflight first, an extra OPTIONS round trip, and the server must list the header in Access-Control-Allow-Headers. The server can cache that answer with Access-Control-Max-Age so the preflight isn't repeated on every call.
Only add the header to your own APIs: sending it to a third-party API can make its requests fail CORS. Tracing SDKs have a setting for this, such as an allowlist of URLs to propagate to.
Sometimes the server starts the trace, for example for the HTML document itself. The server can send its trace ID back in a response header, or in a Server-Timing header that the page reads through the Performance API (cross-origin responses also need Timing-Allow-Origin). Put the trace or request ID in your API error bodies too, so the error screen can show "Reference: 4bf92f35" and support can find the exact trace.
Check your understanding · Exercise 8
After adding traceparent to every fetch, image uploads to a third-party storage service start failing. Why? Pick one.
The endpoint, cost, and what breaks in production
The other end of the pipe is an endpoint on the public internet that accepts writes from anyone, with no login required, at high volume. Treat it like any public API, with a few extra rules because the callers are browsers that may be closing.
What the endpoint does
- Accept fast.
POSTa batch, reply204 No Contentin a few milliseconds, and do the real work later from a queue. Beacons can't read the response anyway. - Accept the formats you send.
sendBeaconwith a string body arrives astext/plain; parse it as JSON anyway. - Limit size. Reject bodies over, say, 64 KB and batches over a few hundred records. Cut long strings.
- Validate. Check each record against the schema; drop unknown events and fields; count drops by reason so you notice a bad release.
- Enrich on the server. Add
receivedAt, the country from the IP address, and the parsed user agent, then drop the raw IP address if you don't need it. - Scrub again, then write to the queue that feeds the log store.
204; storage happens later, from the queue.Rate limits and abuse
Anyone can copy the request from devtools and replay it a million times, or fill your logs with fake errors to hide a real attack or run up your bill. You can't stop this with secrets: any key shipped in the bundle is public. Instead, make abuse cheap to absorb and easy to spot.
- Rate-limit per IP and per session, for example 60 batches per minute per session and a higher cap per IP for offices behind one address. Reply
429and drop the excess. - Cap per session in the client too, say 1,000 records per session, so one stuck loop on one device can't flood you.
- Mark trust. Records are untrusted claims. Never use them to grant anything, bill anyone, or trigger actions.
- Watch the endpoint itself: requests per second, reject rate, and bytes per session. A jump from one network suggests abuse; a jump everywhere after a deploy suggests a logging bug.
- Kill switch. Ship the sampling rates in remote config, so you can turn
infodown to 0% in minutes without a deploy when volume explodes.
Getting past blockers, honestly
Content blockers drop requests to well-known vendor domains. Many teams route logs through their own domain instead, such as a path on the app's origin that the server or CDN forwards to the vendor (Sentry calls this its tunnel option). Keep it honest: respect the user's consent choices whatever the domain, and pick a plain path rather than one designed to dodge filters.
What it costs
Log vendors often charge per GB ingested and per million records indexed, plus more for longer retention. The levers, in order of effect: what you log at all, the sampling rate, the size of each record, how many fields are indexed, and how long you keep it. Compression on the wire (gzip with the CompressionStream API) saves user bandwidth; it doesn't change what the vendor counts once it's unpacked.
Check your understanding · Exercise 9
Log volume triples overnight. Every extra record is info level widget_rendered from the latest release. What are good first moves? Select all that apply.
What breaks in production, and how you notice
| Failure | How you notice | Fix |
|---|---|---|
| Logs from the last seconds before leaving are missing | Funnels show users vanish mid-step; seq gaps at the end of sessions | Flush on visibilitychange to hidden, keep the last batch small, store refused batches for the next visit |
| The logger itself throws and breaks a feature | Errors whose stack points into the logger | Wrap every logger entry point in try/catch; never let logging throw |
| A log call inside a render or scroll handler | Volume jumps after a deploy; one event dominates | Per-session caps and de-duplication; remote kill switch |
| Logs pile up offline and burst on reconnect | Spikes of old ts values; 429s from the endpoint | Cap the stored queue, drop oldest first, retry with backoff and jitter |
| Personal data in logs | A scan of the store for email or card patterns finds hits | Allowlist fields, reduce URLs, scrub on both sides, delete affected data |
| Clock skew breaks ordering | Records appear to happen before the page loaded | Order by seq within a session; trust server receivedAt across sessions |
| A large share of sessions sends nothing | Server-side page views far above log sessions | First-party endpoint path; compare against server counts and report the gap |
Logging can also hurt speed: JSON.stringify is synchronous work on the main thread. A 30 KB batch is cheap, but large batches, deep objects, and frequent flushes add up on a cheap phone. Keep batches small, serialize in idle time with requestIdleCallback where available, or move the buffer into a Web Worker. Heavy logging inside an input handler can push Interaction to Next Paint (INP, see the Core Web Vitals lesson) over budget.
How real tools do it
- Sentry keeps a ring of breadcrumbs (clicks, navigations, network calls, console output) and attaches them to each error. It has
sampleRatefor errors,tracesSampleRatefor traces, abeforeSendhook to scrub or drop events, and atunneloption. - Datadog's browser SDKs batch records in the page, send what is left when the page may be closing, and sample by session with
sessionSampleRate. Its RUM SDK adds trace headers, includingtraceparent, only to URLs listed inallowedTracingUrls. - OpenTelemetry for the web gives vendor-neutral tracing: it creates spans for
fetchand document load, addstraceparentto allowed URLs, and exports spans with a batch processor that has a queue cap, a batch size, and a delay: the same size and timer knobs as the lab above. - Big product companies often run their own pipeline: a first-party endpoint, a shared event schema with a reviewed registry, and per-event sampling rates in remote config. The analytics lesson later in this module covers event registries.
Check your understanding · Exercise 10
Your server counts 100,000 page views yesterday, but logs show only 82,000 sessions with any record. Info sampling is 100% for this test. What explains the gap? Select all that apply.
Using it in a system design interview
Logging is rarely the whole question, but it comes up in almost every app design as "how would you know it works in production?" or "how would you debug a report from one user?". It also comes up directly: "design a client-side logging SDK" or "design the analytics pipeline for our web app".
When to bring it up
- In the non-functional requirements: "we need to see client errors and key funnel steps in production, at low cost, without personal data".
- When you add something that can fail silently on the client: an offline queue, optimistic updates, a retry, a third-party widget.
- At the end, in a short "monitoring" section: logs, errors, Web Vitals, and the alerts you'd set.
What to draw
One row is enough: app code, a logger box (enrich, scrub, sample, buffer), a flush arrow labelled "batch: 30 records or 10 s or hidden", an ingest endpoint with a rate limiter, a queue, and a log store. Add the traceparent arrow to the API, and a dashed line from both the API and the endpoint into the same store. Then talk through one record end to end.
| Level | What a strong answer covers |
|---|---|
| Junior | Structured events with a session ID; batch instead of one request each; don't log passwords; send errors to an error tracker. |
| Mid | Levels with rates; flush on visibilitychange with sendBeacon; per-session sampling; URL and field scrubbing; release version on every record. |
| Senior | Head vs tail sampling with a ring buffer; traceparent to join backend traces and the CORS cost; the 64 KiB keepalive budget; endpoint validation, rate limits, and a kill switch; cost math; consent and retention. |
Think first
How would you size the batch and flush interval for a logging SDK?
Start from the limits: the hide-time batch must fit well under the 64 KiB keepalive budget, shared with other beacons on the page. At about 300 bytes a record, 30 records is about 9 KB.
Then the trade-off: bigger batches mean fewer requests but more records at risk and more delay. A 10-second timer bounds the delay; the hide flush bounds the loss.
Finally, measure: compare server page views with log sessions, and watch seq gaps at the end of sessions to see how much you still lose.
Summary
- Browser logs show what the server can't see, but they come from millions of untrusted, unreliable devices: some will be blocked, lost, or faked.
- Log structured records with a stable event name, level, session ID, release, route, and trace ID; keep fields few and low-cardinality.
- Never send one request per record: buffer and flush on size, on a timer, and when
visibilitychangesays the page is hidden. - Use
sendBeaconorfetchwithkeepalivefor the last flush, keep it under the shared 64 KiB budget, and store refused batches for the next visit. - Sample per session with a stable hash, keep every error, attach a ring buffer of recent steps to errors, and record the rate so counts can be scaled back.
- Scrub in the browser with an allowlist, route patterns, masks, and length caps, then scrub again on the server; respect consent.
- Send a W3C
traceparentto your own APIs and log the same trace ID, so one search joins the click with the backend error. - Treat the ingest endpoint as a public API: fast
204, size limits, validation, rate limits, and a remote kill switch for sampling.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Users report that a button sometimes does nothing, but backend logs show no errors. How do you find out what's happening?
The request probably never leaves the browser, so the evidence is on the client. I'd add structured events around the flow: pay_clicked, payment_requested, and payment_failed with a reason, each with session ID, release, route, and browser.
I'd make sure caught and uncaught errors go to error tracking with breadcrumbs, so an exception in the handler shows the steps before it.
Then I'd query for sessions with a click but no request, grouped by browser and release. That usually points to one browser version or one deploy within minutes.
Question 2
Why not just send each log with fetch as it happens?
One request per record costs headers, radio wake-ups, and connection slots on every device, and competes with the user's real requests.
A normal fetch started as the page closes is usually cancelled, so the most important records (right before leaving) are lost.
Instead I buffer in memory and flush on size, on a timer, and on visibilitychange to hidden, using sendBeacon or fetch with keepalive for that last flush so it outlives the page.
Question 3
How do you keep logging costs under control at 10 million sessions a day?
Sample per session for low levels (say 5% of info), keep all errors, and drop debug in production, with rates in remote config so I can change them without a deploy.
Keep records small and the schema tight: stable event names, few indexed fields, no high-cardinality values like IDs in indexed fields.
Put caps in the client (records per session, repeated-record de-duplication) and rate limits at the endpoint, and keep debug logs for 14 to 30 days, not forever.
Question 4
How do you connect a browser error to the backend request that caused it?
The browser creates a trace ID per action and sends it in a W3C traceparent header on calls to our own APIs, and writes the same ID into its log records.
The backend tracing library continues the trace and stamps the ID on its logs and spans, so one search shows both sides.
Cross-origin, the header triggers a CORS preflight, so the API has to allow it and should cache the preflight; and I wouldn't send it to third-party APIs.
Question 5
How do you make sure no personal data ends up in logs?
Allowlist fields in the schema so unknown fields are dropped, log route patterns instead of full URLs, mask patterns like emails and card numbers, and cap string lengths, all in the client before sending.
Run the same checks at the endpoint, because clients can be old or fake.
Use random pseudonymous IDs instead of emails, respect consent choices in the logger, set short retention, and scan the store regularly for leaks.
Practice it
These design problems lean on logging in the browser.
References
- Navigator: sendBeacon() method MDN: how beacons work and why to send them on visibilitychange.
- Document: visibilitychange event MDN: the last reliable event before a page may be discarded.
- Page Lifecycle API Chrome for Developers: active, passive, hidden, frozen, and discarded states.
- Deprecating the unload event Chrome for Developers: the staged rollout that stopped unload handlers from running by default.
- Trace Context W3C Recommendation defining the traceparent and tracestate headers.
- OpenTelemetry JavaScript: browser Vendor-neutral tracing and export from the browser.
- Sentry JavaScript: filtering and sampling beforeSend, sample rates, and dropping events in a real SDK.
Read next
- Error trackingCatching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.