A Method for Frontend Bugs
Reproduce, work the layers in order, narrow by bisection, fix, and then prove the signal moved. The method is the skill; the panels are interchangeable.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I get from "it is broken for some users" to a fix I can show actually changed the thing that was broken?
Someone was trying to do an ordinary thing — submit a form, open a page, see their own data — and the interface did something other than what they expected. They want it to work, and they want to stop being the person who has to work around it.
Read the report, recognise the shape of it from experience, change the code that looks wrong, and ship. Experience is usually right, and building a reproduction is slower than reading the diff.
Experience is pattern matching, and frontend symptoms are famously ambiguous: "the button does nothing" is a dead listener, a failed request, a swallowed exception, a covered element, a disabled control, a long task, or a state update that never reached the DOM. Seven causes, one symptom.
- Experience is pattern matching, and frontend symptoms are famously ambiguous: "the button does nothing" is a dead listener, a failed request, a swallowed exception, a covered element, a disabled control, a long task, or a state update that never reached the DOM. Seven causes, one symptom.
- Without a reproduction there is no way to tell a fix from a coincidence. Intermittent bugs "go away" after every deploy, and come back when the traffic that triggered them does.
- The layer with the symptom is usually not the layer with the cause. A layout that jumps is often a network problem; a click that does nothing is often a state problem; a page that freezes is almost never the code you are staring at.
- The developer machine is the single worst place to reproduce a frontend bug: fastest CPU, warm cache, one browser, one screen size, one account, one locale, no extensions, no proxy, and a service worker you cleared last week (The Browser Is a Runtime).
- Fixing without verifying moves the ticket, not the experience. The most expensive bugs in a frontend backlog are the ones closed twice.
What is actually happening
In the browser, not in the framework.
- A frontend bug is a disagreement between what you believe the system did and what it actually did. Debugging is the process of removing beliefs, one layer at a time, until only the truth is left.
- The layers are not arbitrary: they are the stages a user action actually passes through — event, application logic, state, network, response handling, DOM mutation, style, layout, paint (The Frontend Reasoning Loop). Each stage can be inspected independently, and each is cheaper to exclude than the one after it.
- Working them in a fixed order is a search strategy, not bureaucracy. Network first because a missing or wrong response explains a huge share of "wrong content" bugs and takes seconds to rule out. Console next because an exception mid-handler explains "nothing happened" and is already recorded. DOM and CSS before performance because "invisible" and "slow" look identical to a reporter.
- Bisection is the engine of the whole method. Every question you can answer with "yes on this side, no on that side" halves the space: which release, which route, which account, which device class, which third-party script, which feature flag (Feature Flags in the Client).
- Verification is a measurement, not a feeling. Something was wrong in a way that was observable — a failing request, an exception count, a stale value, a saturated main thread, a growing heap — and the fix is done when that observable has moved and stayed moved in the field (Real User Monitoring).
What this makes the browser do
And which of it is avoidable.
- Recording a profile is itself expensive: the browser instruments the main thread, samples stacks and buffers events, which inflates exactly the timings you came to read. Compare shapes and ratios within one recording rather than across a recording and a normal load (Measure Before Optimising).
- Devtools being open changes the page: caching may be disabled, throttling applied, timers and rendering behave differently in a backgrounded window, and holding a reference in the console keeps objects alive that would otherwise be collected.
- Reproducing a cold-cache load means genuinely clearing storage and unregistering the service worker; a hard reload is not the same thing, because a service worker can still be controlling the next navigation (The Service Worker Lifecycle).
- Every extension in the profile you debug with is third-party script running in your page with your page's authority, mutating your DOM and intercepting your requests (Third-Party Scripts and the Supply Chain).
The order, and why it is an order
The sequence below is not a checklist to complete; it is a search over layers, ordered so that the cheapest exclusions come first. Each step has one question, and the point of asking it is usually to rule the layer out and move on. An investigation that produces four confident exclusions and one confirmed cause is a good investigation. An investigation that produces one confident cause and nothing else is a guess that happened to be right.
Two steps are load-bearing and both are habitually skipped. The first is Reproduce: without it every later observation is about a different system. The last is Verify: without it you have changed the code, which is not the same thing as having fixed the problem.
- 1Reproduce
Turns a report into something you can trigger on demand, and writes down the recipe: route, account, data, device, browser, network, sequence.
fails by Reproducing on the development machine only, which changes the CPU, the cache, the browser, the account and the data all at once.
- 2Network
Asks what was requested, in what order, what the server actually returned, and what was served from a cache (Debugging the Network).
fails by Reading status codes only. A 200 carrying an error body and a request that never left because a service worker answered it both look fine at a glance.
- 3Console
Asks what the code said: exceptions, unhandled rejections, framework warnings, security violations reported by the browser itself.
fails by Ignoring the console because it is noisy. Noise is a finding; a swallowed rejection is invisible precisely because someone caught and dropped it.
- 4DOM and CSS
Asks what is actually in the tree and what the computed style really is, as opposed to what the stylesheet says (Inheritance and Computed Style).
fails by Reading the source instead of the live tree. The element you are looking for may exist and be transparent, covered, clipped, or moved off-screen.
- 5Application state
Asks which copy of the data is authoritative and what sequence produced the current one (Debugging State).
fails by Inspecting the snapshot. Most state bugs are a sequence, and a snapshot cannot show a sequence.
- 6Performance
Asks where the main thread went, and whether frames were slow or simply never attempted (Debugging Rendering and Jank).
fails by Profiling the wrong interaction, or profiling with devtools settings that change what you are measuring.
- 7Memory
Asks what is retained and by what, across repeated cycles rather than once (Debugging Memory).
fails by Taking one snapshot. A single number cannot distinguish a leak from a cache that has not been asked to evict yet.
- 8Fix
Changes the cause you demonstrated, at the layer you demonstrated it in.
fails by Fixing the symptom one layer down — clamping a value, adding a timeout, wrapping in a
try— which hides the bug and keeps it. - 9Verify
Shows the signal that was wrong is now right, in the field, on the devices that had the problem.
fails by Checking on the developer machine, which is the environment least able to show the problem in the first place.
Skipping ahead is fine when you have evidence. Skipping ahead because you have a hunch is how an afternoon becomes two days.
Narrowing: bisect the space, not the code
The reason frontend bugs feel hard is that the space is enormous: your code, three framework layers, a bundler, a browser engine, a device, a network path, an account's data, a locale, a set of feature flags, an extension, and whichever version of the client that particular tab happens to be running. You cannot read your way through that. You can halve it repeatedly.
Choose the axis by what is cheapest to split, not by what feels most likely. "Does it happen on the previous release" takes one deploy of a preview build and eliminates every axis except the diff. "Does it happen for another account" takes ten seconds and eliminates half of everything else.
- Write down each split and its answer. Halving a space works only if you remember which half you already discarded.
- A split that says "both halves" is still information: it usually means two bugs, and you have been chasing a superposition of them.
- The most under-used split is "does it happen with JavaScript disabled" — for a server-rendered page it separates markup and CSS problems from application problems instantly (Server-Side Rendering).
You can reproduce it. What is the cheapest question that halves the search space?
when It worked before and you know roughly when it stopped. Bisecting deploys narrows to a diff without reading any of it.
cost Needs deployable historical builds and a marker in error tracking or analytics that says which release each session ran (Release Health).
when Some users are affected and some are not. Splits data shape, permissions, feature-flag assignment and locale in one question.
cost Reproducing as another user needs an audited impersonation path; without one, you are asking the reporter to do the bisection for you.
when It is one page or one flow. Immediately separates shared shell, layout and global state from the route's own code (Route Loading Boundaries).
cost A route that is fine can still be the victim; shared state set on one route often breaks another (Who Owns This State?).
when The report names a phone or a browser, or the symptom is timing or memory shaped. Separates engine behaviour and CPU speed from your logic.
cost Needs real devices or remote debugging. Emulated viewports and throttled CPUs change some things and not others, so a negative result proves less than you want.
when Anything is a candidate: extensions, third-party tags, the service worker, a feature flag, a cache layer. Turning one off is the cleanest possible experiment.
cost Disabling changes timing as well as behaviour, so a bug that disappears may have moved rather than gone (Third-Party Scripts and the Supply Chain).
when It is one record, one locale, one very large list. Separates code from content, which is where a surprising share of frontend bugs live.
cost Copying production data into a reproduction is a privacy decision, and often the wrong one. Reduce to the shape that triggers it, not the record itself.
You do not close it until the signal moved
The last step is the one that separates debugging from tinkering. Something observable was wrong. If you cannot say which observable, you have not finished diagnosing, and the fix you are about to ship is a guess. If you can say which observable and you do not go back and look at it after the deploy, you have shipped a guess with extra confidence.
This is also the moment to be honest about what the fix covers. Suppressing a symptom is sometimes the right call under time pressure — a guard that stops the exception, a fallback that stops the blank screen — but it needs recording as mitigation rather than resolution, or the same bug returns wearing a different symptom (Network Failures Only the Client Can See).
Fix deployed. "Checked on staging in Chrome on my laptop — looks fine." Ticket closed.
Before: the interaction queued behind a long task on every mid-range Android session that opened the panel; visible as a spike in the field interaction metric and as a single long task in the Performance panel recording. Fix: the work is chunked and yields between chunks. After: no long task in the same recording on the same device profile; the field interaction metric for that route returns to its pre-regression distribution and stays there across the next release. Also: a budget test on that interaction, so the next regression fails in CI instead of in support.
The first version proves the fix did not break the happy path on the fastest available machine, which was never in doubt. The second names the observable that was wrong, checks the same observable afterwards on the population that had the problem, and leaves something behind that fails the next time it moves (Measure Before Optimising).
How to build it
Most important first.
- Reproduce first, and write down the recipe. Route, account, data, device class, browser, network condition, and whether it happens on first load or only after some sequence. If the recipe needs a sequence, that is already a finding (Debugging State).
- Then work the layers in order — network, console, DOM and CSS, application state, performance, memory — and record what each one *excluded*, not just what it showed. Exclusions are the durable part of the investigation.
- Narrow by bisection on whichever axis is cheapest to split: release, route, user, device class, or "does it survive with this thing disabled".
- Form one hypothesis at a time and make it falsifiable. "If the response is arriving out of order, forcing them to arrive in order will change the outcome" is a hypothesis; "something is racy" is a mood (Out-of-Order Responses).
- Fix the cause you demonstrated, and separately note the causes you excluded — those notes are what stop the next person repeating the search.
- Verify by showing the signal moved: the failing request stops failing, the exception stops appearing in error tracking, the interaction stops queueing behind a long task, the heap stops growing across navigations (Frontend Error Tracking).
- Leave a regression test at the level the bug lived at. A race gets a test that forces the ordering; a rendering cost gets a budget; a broken keyboard path gets a keyboard test (Choosing the Test Level).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A keyboard pass is the cheapest bug-finding tool in frontend engineering and takes under a minute: tab through the flow, and note where focus disappears, where it jumps backwards, where a control cannot be reached, and where
Escapedoes nothing (Keyboard Operability). - A screen-reader pass finds a different class of bug entirely — controls with no accessible name, state changes nobody announces, headings that describe a structure the page does not have. These are invisible to every other test you run (The Accessibility Tree).
- "It does not work with a screen reader" needs a reproduction like any other report, and the recipe has extra required fields: which assistive technology, which version, which browser, which mode (browse or focus), and which platform. The same page behaves differently across those combinations, and a report without them is not yet reproducible (Accessibility Testing).
- Debug with the assistive technology the report came from where you can. A pairing you do not have is a reason to ask the reporter for the sequence, not a reason to guess at semantics.
- When you fix an accessibility bug, verify with the same tool that found it. A passing automated check does not show that focus now lands somewhere sensible or that the announcement is comprehensible (Semantics Before ARIA).
What can go wrong
- The reproduction only works on your machine, so every subsequent observation describes a different bug from the one users are hitting.
- You fix the first plausible thing you find. Frontend code has many plausible things; the one you found first is not evidence.
- The bug stops reproducing while you are investigating, and you record that as fixed. Intermittent means unexplained, and unexplained means it is still shipped (Heisenbugs: The Bug That Leaves When You Look at It in Concurrency).
- Devtools changes the behaviour and you debug the changed behaviour: cache disabled hides a caching bug, throttling hides a race, an open console keeps a leaked object alive.
- Instrumentation added during the investigation is left in — a
console.login a render path, a listener that is never removed, a debug flag that ships enabled. - The verification is done by the person least able to see the problem, on the device least likely to show it, with the cache most likely to hide it.
- A bug that reproduces only sometimes is usually a race. Two responses that can arrive in either order, an effect that can run before or after a state update, a click that can land before hydration finishes, a service worker that can activate before or after the first fetch (Hydration).
- The act of debugging changes the timing: a breakpoint, a log, an open devtools window or a throttled CPU all reorder the events you are trying to observe, so the bug can vanish exactly when you look at it.
- Fixes for races can themselves race — a guard that checks a flag and then acts is two operations, and the state can change between them (Out-of-Order Responses).
- Reproductions carry real data. A HAR export contains request and response bodies, cookies and
Authorizationheaders; "copy as fetch" copies credentials with it. Treat both as secrets, not as attachments (Session Replay and the Privacy It Costs). - Debugging in production with a real user's session means acting as that user. Impersonation needs to be an audited, authorized capability on the server, not a token pasted into a console (What the Frontend Is Responsible For in Auth).
- Pasting code into the console is arbitrary code execution in your own origin. It is also the exact social-engineering attack browsers warn about, and users have been talked through it (The Browser Security Model).
- Verbose client-side diagnostics shipped to production leak internal structure: endpoint names, feature flags, role names, and sometimes payloads. Ship the ability to enable diagnostics, not diagnostics enabled (Frontend Error Tracking).
- "I know what this is." Frequently true, and the cost of checking is one minute. The cost of being wrong is a day and a second deploy.
- "It reproduces intermittently, so it is a flaky environment." Intermittent almost always means ordering-dependent: two things race, and which wins depends on timing you do not control (Out-of-Order Responses).
- "It works now." After a deploy, a cache clear, or a page refresh, "works now" describes the state of your machine and nothing else.
- "The stack trace points at the framework, so it is a framework bug." The frame at the top of the stack is where the exception surfaced, not where the wrong value came from.
- "We could not reproduce it, so we closed it." Not reproducible is a status, not a resolution — and if several users reported it, the reproduction exists and you have not found it.
Measuring it, and what changes in the field
- Error tracking answers "is this happening to anyone else, since when, and on what": the release marker next to a spike is often the whole investigation (Release Health).
- The Network panel answers what was requested and what came back; the Console answers what the code said about it (A Mental Model of the Devtools).
- The Performance panel answers where the main thread went, which is the only honest answer to "it feels slow" (Long Tasks).
- Field data tells you whether the fix reached users; local measurement only ever tells you whether it works on one machine (Vitals in the Field).
- On a slow device, timing-dependent bugs reproduce that never reproduce locally: handlers race, animations drop frames, and a sequence you never manage to trigger by hand happens on every load (Interaction Responsiveness).
- On a slow or flaky network, request ordering changes and every optimistic path gets exercised. Throttling is not simulation of a bad network, but it changes the ordering enough to surface a whole class of bug.
- In a long-lived tab, the bug depends on history: what was cached, what was retained, which version of the client is running, how many listeners have accumulated (Long-Lived Clients and Version Skew).
- With a large dataset, cost bugs appear that a seeded development database will never show — a list that is fine at fifty rows and unusable at fifty thousand (List Virtualization).
- Across a deploy, an old tab keeps talking to a new server. "Only some users, and only until they refresh" is the signature of version skew, not of a code path (Deploying a Frontend).
- Building a reliable reproduction is slower than guessing, and it is genuinely wasted effort on the bugs where the first guess was right. It is the only thing that works on the ones where it was not, and you cannot tell which is which in advance.
- Working the layers in order means spending time excluding things that turn out to be fine. Those exclusions are what make the search converge instead of oscillating.
- Instrumenting for verification costs code, bytes and sometimes privacy review. Without it, "fixed" is an opinion.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe order — reproduce, network, console, DOM and CSS, state, performance, memory, fix, verify — follows from the stages a user action passes through, so it holds regardless of browser or framework. Only the tool you use at each step differs.
- BROWSER-SPECIFICEvery step names a tool, and the tools differ substantially: Chromium, Firefox and Safari disagree on panel names, on which measurements they expose at all, and on how remote debugging for a mobile device is attached. Treat any panel name here as an example of the question, not as an instruction (A Mental Model of the Devtools).
- DEVICE-SPECIFICWhich bugs reproduce at all depends on the device class: timing-dependent bugs surface on slow CPUs, memory pressure surfaces on low-memory phones where the browser discards backgrounded tabs, and neither reproduces on a development laptop.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — turning a reproduction into a regression test that keeps failing until the cause is gone, and deciding which level of the pyramid the test belongs at.
- — Software Design — why some codebases are debuggable and others are not: explicit state transitions, narrow interfaces, and errors that carry the context of where they came from.