Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A10
38 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA10 · Part 3 · Advanced

Browser observability

The bug is on somebody else's device, on a network you cannot reproduce.

5 min read9 sections2 exercises
38 of 41

In this chapter

  • Why local debugging runs out
  • Catching what escapes
  • Source maps, and why the stack is unreadable without them
  • Send context, not just the error
  • Rate-limit your own reporting

and 4 more

about 5 min
09On this page
  1. Why local debugging runs out
  2. Catching what escapes
  3. Source maps, and why the stack is unreadable without them
  4. Send context, not just the error
  5. Rate-limit your own reporting
  6. Field data beats lab data
  7. Finding what blocked the main thread
  8. What to do before you debug
  9. The privacy part, which is not optional

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 callsent++ >= 5?sent afterfetch sent?
10false1yes
21false2yes
32false3yes
43false4yes
54false5yes
65true6no — capped
76true7no — 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 machineField — real users
One device, one network, cold cacheEvery device, every network
Reproducible, good for before-and-afterNot reproducible, and true
import { onLCP, onINP, onCLS } from "web-vitals";

onINP((m) => send({ name: m.name, value: m.value, rating: m.rating, route }));
MetricMeasuresGood
LCPWhen the main content appeared< 2.5s
INPHow long an interaction takes to show a result< 200ms
CLSHow 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

  1. When did it start? Check the release timeline before opening any code.
  2. Who is affected? One browser, one country, one device class narrows it enormously.
  3. Can you turn it off? A feature flag or a rollback converts an incident into an investigation you can do calmly.
  4. 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 unhandledrejection needs its own listener separate from the error event.
  • 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.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 9 testsA rate-limited error reporteradvanced
  • 9 testsAn error reporter that batches and de-duplicatesadvanced
All 31 for this level →
A10

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A9Previous · A9Testing
  2. A10you are here
  3. Next · A11JavaScript interview bankThirty-four questions, split by the level they are asked at, with the answer that lands.14 min read

01On this page

9 sections · 0%

  1. 01Why local debugging runs out
  2. 02Catching what escapes
  3. 03Source maps, and why the stack is unreadable without them
  4. 04Send context, not just the error
  5. 05Rate-limit your own reporting
  6. 06Field data beats lab data
  7. 07Finding what blocked the main thread
  8. 08What to do before you debug
  9. 09The privacy part, which is not optional

[] chapters · t top