A10 · Part 3 · Advanced
Browser observability
The bug is on somebody else's device, on a network you cannot reproduce.
09On this page
Why local debugging runs out
Your machine is fast, your connection is good, your cache is warm, and you have the exact state you just created. None of that is true for the person filing the bug — and most of the time nobody files one at all. They leave.
Observability is the set of things you put in place so that a session you were not in can still tell you what happened.
Catching what escapes
window.addEventListener("error", (e) => {
report({ message: e.message, stack: e.error?.stack, source: e.filename, line: e.lineno });
});
window.addEventListener("unhandledrejection", (e) => {
report({ message: String(e.reason?.message ?? e.reason), stack: e.reason?.stack });
});
Two listeners, and the second is the one people forget. A rejected promise with
no .catch never touches window.onerror, so an entire
class of async failure goes unrecorded until you add it.
A third exists for resources: window.addEventListener("error", fn, true)
with capture on fires for images and scripts that fail to load, which regular
error handling misses entirely.
Source maps, and why the stack is unreadable without them
TypeError: e.b is not a function
at t (main.9f2a1c.js:1:48213)
That is what a minified stack tells you: nothing. A source map maps those positions back to your real files and line numbers.
Generate them, upload them, do not serve them
Build with source maps, upload them to your error tracker from CI, and then
do not deploy them to the browser. Serving a source map publishes your
original source, comments and all. Most bundlers have a "hidden" mode that
emits the map without the //# sourceMappingURL comment, which is
exactly this.
Send context, not just the error
report({
message, stack,
release: __APP_VERSION__, // which deploy
url: location.pathname,
userAgent: navigator.userAgent,
breadcrumbs: recentActions, // the last ~20 things that happened
});
The single most useful field is the release. "This started at 14:20 on Tuesday, which is when 4.12 shipped" answers more bugs than any stack trace, because most production bugs are explained by the diff.
Breadcrumbs — a rolling buffer of navigations, clicks, network calls and console messages — are the second. An error message tells you what broke; breadcrumbs tell you what the person was doing.
Rate-limit your own reporting
let sent = 0;
function report(payload) {
if (sent++ >= 5) return; // per session
fetch("/api/client-error", {
method: "POST",
body: JSON.stringify(payload),
keepalive: true, // survives the page unloading
}).catch(() => {});
}
A render loop that throws every frame will happily send ten thousand reports
and take your logging endpoint down with it. Cap per session. And
keepalive matters: without it, a report fired as the user
navigates away is cancelled — which is exactly when errors happen.
Dry run: report() across seven calls in one session
| Call # | sent before this call | sent++ >= 5? | sent after | fetch sent? |
|---|---|---|---|---|
| 1 | 0 | false | 1 | yes |
| 2 | 1 | false | 2 | yes |
| 3 | 2 | false | 3 | yes |
| 4 | 3 | false | 4 | yes |
| 5 | 4 | false | 5 | yes |
| 6 | 5 | true | 6 | no — capped |
| 7 | 6 | true | 7 | no — capped |
sent++ reads the value before incrementing, so the fifth
call still gets through — 5 >= 5 only turns true
starting on the sixth. Five real reports per session, then silence,
which is exactly what stops a throwing render loop from taking the
logging endpoint down with it.
Field data beats lab data
| Lab — Lighthouse, your machine | Field — real users |
|---|---|
| One device, one network, cold cache | Every device, every network |
| Reproducible, good for before-and-after | Not reproducible, and true |
import { onLCP, onINP, onCLS } from "web-vitals";
onINP((m) => send({ name: m.name, value: m.value, rating: m.rating, route }));
| Metric | Measures | Good |
|---|---|---|
| LCP | When the main content appeared | < 2.5s |
| INP | How long an interaction takes to show a result | < 200ms |
| CLS | How much the layout jumped | < 0.1 |
Read the 75th percentile, never the mean. An average hides the quarter of your users on a mid-range Android phone, and they are the ones leaving. A metric that is fine on average and terrible at p75 is the most common performance situation there is.
Finding what blocked the main thread
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) send({ type: "long-task", duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
Anything over 50ms on the main thread is a task the browser could not interrupt, which means input during it felt dead. Long tasks are what a bad INP is made of, and this is how you find them in the field rather than guessing on your laptop.
What to do before you debug
- When did it start? Check the release timeline before opening any code.
- Who is affected? One browser, one country, one device class narrows it enormously.
- Can you turn it off? A feature flag or a rollback converts an incident into an investigation you can do calmly.
- Then reproduce — with 4× CPU throttling and a slow network, not on your machine as it is.
The privacy part, which is not optional
Error payloads pick up more than you intend: URLs with tokens in the query string, form values in breadcrumbs, email addresses in a stack's local variables. Session replay records the screen. Strip, mask and allow-list before sending, and know what your privacy policy actually promises — this is the one area where a debugging convenience can become a legal problem.
Interview answer, one sentence
"Two listeners — error and unhandledrejection,
because a rejected promise never reaches the first — with source maps
uploaded from CI but never served, and every report tagged with a release so
'when did this start' is answerable. Then field vitals at p75 rather than lab
numbers, because the mean hides the users who are actually suffering."
Before you move on
- Explain why
unhandledrejectionneeds its own listener separate from theerrorevent. - Say why source maps should be uploaded to an error tracker but never served to the browser.
- Walk through the
report()rate-limiter dry run and say why the fifth call still gets through but the sixth doesn't. - Explain why p75 is the number to read on a metrics dashboard, not the mean.
- Name the checks to run before opening any code when a new bug report comes in.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series