DebuggingGENERALFRAMEWORK-SPECIFICBROWSER-SPECIFIC

Debugging State

A wrong value on screen is the end of a sequence, not a snapshot. Reconstruct the transitions, then answer the question that resolves most of these bugs: which copy of this data is authoritative?

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.

The question

The data on screen is wrong — which copy of it is authoritative, and what sequence of events produced the one I am looking at?

The user intent

Someone changed something — saved a record, applied a filter, switched a toggle — and the interface is showing them something other than the truth. They no longer know whether their change was saved, which is a worse feeling than an error message.

The obvious build

Inspect the state. Open the store or the component tree, look at the current value, find where it is set, and correct that code.

Why it breaks

The current value is the *result*. A snapshot cannot tell you whether it was set once wrongly, set correctly and then overwritten, or set correctly twice by two writers that disagree (Who Owns This State?).

How it breaks in a real browser
  • The current value is the *result*. A snapshot cannot tell you whether it was set once wrongly, set correctly and then overwritten, or set correctly twice by two writers that disagree (Who Owns This State?).
  • The same fact usually exists in several places at once: the server, a cache entry, a component's local state, the URL, a form field, persisted storage and another open tab. Any one of them can be right while the one you are reading is stale (State Synchronization).
  • Bugs that only reproduce sometimes are ordering bugs, and ordering is invisible in a snapshot. A stale response overwriting a fresh one leaves no trace in the final value (Out-of-Order Responses).
  • Inspecting changes the sequence. A breakpoint lets a pending response land; an expanded object in the console shows the value at expansion time; a devtools pause reorders effects (A Mental Model of the Devtools).
  • Half of these bugs are not in your state at all — they are in derived values that were memoised against the wrong dependencies, so the source updated and the derivation did not (Derived State).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Application state is a set of transitions over time, and the UI renders a projection of the latest one. Debugging it means recovering the transitions, because the projection alone is under-determined (The Seven Kinds of State).
  • Every category has a different owner and a different staleness model: server state is a cached copy of something you do not control; URL state is owned by the history entry; form state is owned by the DOM until you take it; persisted state outlives the tab that wrote it (Server State Is Not Your State).
  • Most "wrong data" bugs are one of four shapes: stale (an old value was never invalidated), overwritten (a newer value was replaced by an older one), duplicated (two copies diverged), or derived incorrectly (the source is right and the computed value is not).
  • Effects reintroduce ordering. A subscription, a fetch on mount, a synchronisation to storage and a route change can all run in an order that varies with timing, and each can write state (The Life of a Fetch).
  • Time-travel tooling exists precisely because sequence matters — replaying transitions is the direct answer to "what happened". It is framework-specific and only sees the transitions that went through the mechanism it hooks, which is why local component state and DOM state routinely fall outside it.

What this makes the browser do

And which of it is avoidable.

  • Every state change that reaches the DOM costs the pipeline: reconciliation, mutation, style, possibly layout and paint. A bug where state is set twice therefore often shows up as double rendering work before it shows up as a wrong value (What a Component Costs to Render).
  • Persisted state costs synchronous work when it is read from Web Storage on the main thread, and its cost is paid on every read, not on every change (localStorage and sessionStorage).
  • Broadcasting state between tabs, whether through storage events or a channel, wakes other documents and makes them do rendering work you did not ask for (Auth Across Tabs).
  • Holding large derived structures in state means holding them in memory for the lifetime of whatever owns them, which is a leak when the owner outlives the need (Debugging Memory).

Reconstruct the sequence, not the snapshot

FRAMEWORK-SPECIFICThe log below is deliberately framework-free so it works anywhere. Where a store or signal library provides transition recording or time-travel replay, that tooling is better — but it is specific to that library and blind to local component state, uncontrolled form fields, storage writes and other tabs, all of which write the same facts.

The instinct is to inspect the state and work backwards from the wrong value. That works for the easy half of these bugs and fails completely for the interesting half, because the value you are looking at is the last frame of a film. Two writes in the wrong order, an effect that ran twice, a response that arrived after the user had moved on: all of them produce a final value that looks like a single incorrect assignment.

The cheapest instrument is a bounded, append-only log of transitions, added temporarily during the investigation. It costs a few lines, it records exactly the thing a snapshot cannot, and unlike a breakpoint it does not change the ordering it is trying to observe. Where the framework has its own transition tooling, use it — but note that it records only what passes through the framework, which is a subset of the writes that matter.

Rebuilding the sequence
  1. 1
    Name the fact

    Decides precisely which value is wrong — "the order list for account 42 on the dashboard", not "the orders".

    fails by Staying vague, so you end up watching three different values change and cannot tell which one broke.

  2. 2
    Enumerate the copies

    Lists every place that fact exists: server, cache entry, component state, URL, form field, storage, other tabs (The Seven Kinds of State).

    fails by Stopping at the store, and missing the copy in the URL or the one another tab wrote.

  3. 3
    Name the writers

    Lists everything that can write each copy: responses, effects, user input, subscriptions, restoration from storage.

    fails by Forgetting the restoration path, which runs once at startup and is therefore invisible during interaction.

  4. 4
    Record transitions

    Logs each write with its origin and a monotonic timestamp.

    fails by Logging values without origins, which shows what happened but not who did it.

  5. 5
    Reproduce with real latency

    Throttles or delays responses so the orderings that occur in the field can occur locally (Debugging the Network).

    fails by Reproducing at full speed, where the fast path always wins and the bug never appears.

  6. 6
    Read the ordering

    Finds the transition that should not have happened, or the two that happened in the wrong order.

    fails by Accepting the first suspicious write; there are usually several, and only one is out of order.

A temporary transition log for a value that keeps going stale
1type Transition = {
2 at: number // monotonic, not wall clock: ordering is the point
3 key: string // which fact
4 from: unknown
5 to: unknown
6 by: string // "response:orders@v3", "effect:mount", "user:toggle"
7 reqId?: string // which request this came from, if any
8}
9
10const log: Transition[] = []
11const LIMIT = 200
12
13export function record(t: Omit<Transition, 'at'>) {
14 log.push({ ...t, at: performance.now() })
15 if (log.length > LIMIT) log.shift() // bounded: this is debug code that must not leak
16}
17
18// At the point the UI is wrong, read the story rather than the value:
19export function storyFor(key: string) {
20 return log.filter((t) => t.key === key)
21}

The by field is what makes this work. "The value went from 3 to 3 to 1, and the last write came from a response tagged orders@v1 after one tagged orders@v3" is a diagnosis; "the value is 1" is not. Bound the log and strip it before it ships — debug instrumentation left in is its own bug (A Method for Frontend Bugs).

Which copy is authoritative?

This is the highest-yield question in the module. Ask it out loud, and a surprising share of state bugs answer themselves — because the honest answer is often "nobody decided", and once two places hold the same fact with no designated source, disagreement is a matter of time rather than of correctness (State Synchronization).

The table is a checklist for a specific value in a specific bug. Walk the rows, mark which copies exist for the fact you are debugging, and inspect each one at the moment the UI is wrong. The bug is almost always visible as two rows disagreeing, and the fix is almost always to designate one of them as the source and derive the rest (Derived State).

CopyWho writes itHow it goes staleWhere you inspect it
Server recordYour backend, and anyone else's clientIt does not — everything else is stale relative to itRe-request it, or read the server's own logs (Server State Is Not Your State)
Client cache entryFetch results, background revalidation, optimistic writesNothing invalidated the key after a mutation (Query Keys and Invalidation)The data-layer devtools, or the cache object itself
Component stateEffects, event handlers, props copied on mountIt was copied from a source that has since changedFramework component inspector
URLNavigation, replace calls, the user editing the address barCode changed state without updating the URL, so a reload disagrees (The URL Is Application State)The address bar, and the history entry
Form field (DOM)The user, autofill, the browser restoring a sessionYou read it once into state and the user kept typing (Form State Is a Draft)The Elements panel: check the property, not the attribute
Persisted storageExplicit writes, sometimes from a previous version of the appIt survives deploys, so it can hold a shape your current code no longer expects (Persistent Client State)The Application or Storage panel
Another tabA second copy of your app doing all of the aboveLast writer wins, and neither tab observed the other (Auth Across Tabs)Open the second tab and inspect both together

Symptom to cause

State bugs present as a small number of recognisable symptoms, and each one narrows the search sharply. "It shows the old value" is a different investigation from "it shows the right value and then reverts", which is different again from "it is right until I navigate away and come back".

Note how many of these rows resolve to ordering. That is the point made in the module header: a bug that reproduces only sometimes is usually a race, and naming it as one is most of the diagnosis (Out-of-Order Responses).

What the symptom is telling you
TriggerSymptomCauseResponse
Save succeeds, list still shows the old rowStale value, permanently, until a reloadThe mutation never invalidated the cache entry the list readsInvalidate or update the key the mutation affects, and assert it in a test (Query Keys and Invalidation).
Value updates, then reverts a moment laterA visible flip back to the old dataA background revalidation or a slower earlier response landed after the optimistic updateVersion the optimistic update and discard responses older than the last write (Optimistic UI, Rollback and Reconciliation).
Typing quickly in a search boxResults correspond to an earlier queryResponses resolving out of order with no key checkCancel superseded requests and ignore responses that are not for the current input (Cancelling a Request Nobody Is Waiting For).
Correct after reload, wrong after client-side navigationRoute-dependent stalenessState survived the navigation because it is owned above the route, or the loader reused a cached entryDecide deliberately what is route-scoped and what is app-scoped (Route Loading Boundaries).
Two tabs openOne tab shows data the other has already changedTwo independent copies with no synchronisation, both writing the same persisted keysChoose: synchronise deliberately, or make staleness visible instead of silent (State Synchronization).
Source value is right, displayed value is notA derived list, count or filter disagreeing with its inputA memoised derivation with a dependency it does not declareLog source and derived together; fix the dependency rather than removing the memo (Memoization).
Only after the session has been open a long timeErrors that mention a field the server no longer sendsA client from a previous deployment holding state shaped for the old contract (Deploying a Frontend)Validate restored and persisted state on the way in, and version it (Persistent Client State).
Only on first render after server-rendered HTMLA value that flashes and then changesClient state initialised differently from the markup the server producedMake the server and client derive the initial state from the same input (Hydration Mismatch).

How to build it

Most important first.

  • Write down the sequence you believe happened, then instrument to confirm it. A short append-only log of transitions — what changed, who changed it, with which inputs — turns an unreproducible bug into a readable trace.
  • Ask which copy is authoritative before anything else. Most of these bugs dissolve at the moment someone says out loud that two places hold the same fact and neither is designated the source (State Synchronization).
  • Reproduce the *sequence*, not the end state: navigate the same way, in the same order, with the same latency. If the bug needs a slow response, throttle until you get one (A Method for Frontend Bugs).
  • Check the derived layer separately from the source. Log the source value and the derived value at the same moment; when the source is right and the derivation is stale, the bug is in the dependencies (Memoization).
  • Use the framework's own inspector for what it can see — the component tree, the store, the query cache — and remember it does not see DOM state, storage, or another tab (A Mental Model of the Devtools).
  • When ordering is suspected, make the ordering explicit rather than guarding against it: key requests, ignore responses that are no longer the latest, and version optimistic updates so a rollback can identify what it is rolling back (Rollback and Reconciliation).
  • Prefer a reproduction that is a test. A sequence you can express as a test is a sequence you have actually understood (Component Testing).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A state change nobody announces is a state change that did not happen for a screen-reader user. When you are debugging "did my save work", check whether the success and failure states are announced at all, not only whether they render (Live Regions and Announcement).
  • Focus is state, and it is state that is regularly destroyed by a re-render that replaces the focused node. "It jumps back to the top when the data refreshes" is a state bug with an accessibility symptom (Focus Management).
  • Form state lives in the DOM until you take ownership of it. A controlled input that lags behind the value the user typed is a state bug that a keyboard user hits far harder than a mouse user, because they are typing faster than the round trip (Controlled vs Uncontrolled Inputs).
  • Reproduce state bugs with a keyboard as well: many only occur through the interaction order a keyboard produces — for example blur firing before a click handler runs (Keyboard Events).
  • When state drives disabled or busy states, verify they are exposed as semantics rather than only as styling. An element that looks disabled but is not is worse than one that looks enabled and is (The Rules of ARIA).

What can go wrong

Failure modes
  • A fix that re-synchronises on every render, hiding the ordering bug behind constant overwriting — and quietly costing a render per update forever.
  • Copying server data into local state "so it can be edited", producing two copies that diverge the moment a background refresh lands (Server State Is Not Your State).
  • Adding a guard that checks a flag and then acts. Between the check and the act, another update can land (Out-of-Order Responses).
  • Clearing the cache to fix a staleness bug, which fixes it for the developer and leaves every user with the same stale entry until it expires (Query Keys and Invalidation).
  • Instrumenting so heavily that the logging changes the timing and the bug stops reproducing.
  • Reproducing in a fresh tab only, which is the one state that has no history, no persisted values and no accumulated subscriptions (Long-Lived Clients and Version Skew).
What can arrive out of order
  • Two requests for the same key resolve out of order and the older response wins. This is the single most common frontend state bug and it is invisible in a snapshot (Out-of-Order Responses).
  • An optimistic update and the server's authoritative response can interleave with a background refresh, so a value flips to the right answer, then back to the old one, then forwards again (Optimistic UI).
  • An effect that runs on mount can race a route change, writing state into a view the user has already left (Cancelling a Request Nobody Is Waiting For).
  • Two tabs writing the same persisted key produce a last-writer-wins outcome that neither tab observed (Auth Across Tabs).
  • A state update that lands during hydration can be discarded when the client takes over the server-rendered markup (Hydration Mismatch).
Security
  • Client state is user-controlled. Anything in a store, in storage, or in the URL can be edited by the person using the browser, so a state value must never be what authorises an action (What the Frontend Is Responsible For in Auth).
  • Persisted state outlives the session, and shared devices outlive the user. Debugging with real persisted state means handling real personal data, and a state dump attached to a ticket is a data disclosure (Storage Security and Durability).
  • State restored from an untrusted source — the URL, storage written by another version, a message from another window — should be validated on the way in. A state-shape bug can become a rendering sink bug (Cross-Site Scripting).
  • Authorization-aware UI reads state to decide what to show. When that state is stale, the interface offers actions the server will refuse — which is correct behaviour and a bad experience, and is why the server must remain the authority (Authorization-Aware UI).
Misreads
  • "The value is wrong, so the code that sets it is wrong." Just as often the code that sets it is right and something later overwrites it with an older value (Out-of-Order Responses).
  • "It only happens sometimes, so it is flaky." Intermittent is the signature of ordering. Something races, and locally you always win the race (Reasoning About Races: A Method, Not an Instinct in Concurrency).
  • "Refreshing fixes it, so it is a cache bug." Refreshing resets every copy at once, which tells you almost nothing about which one was wrong.
  • "Time-travel tooling will show me everything." It shows the transitions that went through the mechanism it hooks. Local component state, uncontrolled inputs, storage writes and other tabs are outside it.
  • "Two components disagreeing means one has a bug." Two components disagreeing usually means two copies of one fact, which is a design decision nobody made deliberately (Who Owns This State?).

Measuring it, and what changes in the field

How you would see this
  • A transition log — even a temporary in-memory one — is the highest-value instrument here, because it records the sequence a snapshot cannot (Frontend Error Tracking).
  • Framework devtools for the component tree and the store, with the caveat that they see only what flows through the framework (A Mental Model of the Devtools).
  • The Network panel alongside the state view, so you can align a state change with the response that caused it (Debugging the Network).
  • The Application panel for persisted copies, and a second tab for the copy you forgot about (Auth Across Tabs).
  • In the field, error tracking with enough context to reconstruct the sequence — route, action, and the last few transitions, with personal data excluded (Session Replay and the Privacy It Costs).
Slow device, slow network, large data, old tab
  • On a slow network, ordering bugs become routine: two responses that always arrive in order locally arrive in either order in the field (Out-of-Order Responses).
  • On a slow device, effects and renders spread out in time, so state can be read between two updates that appear atomic on a fast machine.
  • In a long-lived tab, state has accumulated: subscriptions, cached entries, stale user data and a client version that no longer matches the server (Deploying a Frontend).
  • With a large dataset, derived state gets memoised more aggressively, and a wrong dependency list stops being harmless because recomputation was never happening anyway (Memoization).
  • Across tabs, two copies of the app write the same persisted keys, and the last writer wins in an order neither tab controls (Persistent Client State).
What this costs
  • Recording transitions costs memory and can leak personal data into logs. It has to be bounded, scrubbed, and usually opt-in — which makes it slower to reach for than a breakpoint, and far more likely to actually answer the question.
  • Designating one authoritative copy often means giving up a local copy that made something convenient — an editable form fed directly from server state, for instance, needs an explicit draft rather than a mutation of the cache.
  • Making ordering explicit adds code to every place that fetches: a key, a version, a comparison. It is the cost of not having a class of bug that is nearly impossible to reproduce.

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 reasoning — reconstruct the sequence, identify the authoritative copy, classify the bug as stale, overwritten, duplicated or wrongly derived — is independent of framework, because it follows from having more than one copy of a fact rather than from any library's update model.
  • FRAMEWORK-SPECIFICThe tooling is not portable: store inspectors, time-travel replay, signal graphs and query-cache viewers exist for particular libraries and see only what passes through them. React, Vue, Svelte, Solid and Angular expose different things, and none of them shows uncontrolled DOM state, storage writes or another tab (Reactivity Models).
  • BROWSER-SPECIFICWhere you inspect persisted copies differs: Chromium groups cookies, Web Storage, IndexedDB and Cache Storage under one panel, Firefox and Safari present them under differently named tools with different editing capabilities, and remote debugging exposes a reduced subset of all of it.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — the frontend copy of a record is a replica, and "which copy is authoritative" is the browser-side face of replication, staleness and last-writer-wins conflict resolution.
  • Software Design — modelling state as explicit transitions between named states rather than as a bag of booleans, which is what makes an impossible combination impossible to represent at all.