Concepts — A Client-Side Router
Phase 5 · Spec area §34. Prerequisites: fw-02.
0. Orientation
The 60-second version
Routing looks trivial and is not. A router is a state machine over asynchronous, cancellable, user-interruptible transitions, layered on a History API with genuine design quirks.
Almost every hard router bug is a concurrency bug, and once you see that, four separate bugs collapse into one invariant:
Only the most recent navigation may commit.
Stale loader data, back-button-during-load, redirect-during-load, and double-submit are all violations of that single sentence. Stating it in those terms is the difference between patching four symptoms and fixing one thing.
idle ──navigate──► loading ──resolved──► committing ──► idle
│ │
│ └──superseded──► discarded
└──error──────────► error route
The question that separates levels
Everyone can propose a client-side fix for a stale loader. The question that separates a mid-level answer from a senior one is:
What if the loader has a server side effect?
Then no client-side fix is sufficient. AbortController cancellation is best-effort — the request
may already have reached the server. You need idempotency keys, or the operation must not be in a
loader at all. That is an architectural answer rather than a code fix, and it is the seam into
fw-09.
The platform constraint worth knowing cold
You cannot cancel a back navigation. That is a deliberate design decision — allowing pages to
trap the back button would be user-hostile — which is why beforeunload is limited to a
browser-controlled dialog and in-app blocking only works for navigations your router controls.
Knowing why the Navigation API exists is worth more than knowing its method names.
What you should be able to do after this module
| Capability | Evidence you have it |
|---|---|
| Name the invariant | Reduce four router bugs to "only the most recent navigation may commit" |
| Rank the three fixes | Explain why AbortController plus a sequence guard, not either alone |
| Escalate correctly | Recognise when a client-side fix cannot be sufficient |
| Reproduce deterministically | Turn "click fast" into a controlled reproduction |
1. Why this matters
Routing looks trivial and is not. It is a state machine over asynchronous, cancellable, user-interruptible transitions, layered on a History API with quirks. Almost every hard router bug is a concurrency bug: two navigations in flight, a loader resolving after its navigation was abandoned, or the back button racing a redirect.
This is the module where §35's race-condition skills first appear, and the two share a root cause.
2. Build order
- URL parsing and route matching (static, params, wildcards); ranking by specificity.
- History API:
pushState,replaceState,popstate. Note whatpopstatedoes not tell you. - Nested routes and a matched-route chain.
- Redirects, including loops — detect and fail loudly.
- Loaders: async data per route, resolved before the transition commits.
- Cancellation: a navigation superseded by another must abort its loaders.
- Error routes and error boundaries per level.
- Back/forward, including during an in-flight navigation.
- Scroll restoration.
- Blocking navigation on unsaved changes.
3. Failure Lab
- Stale loader. Navigate A→B→C quickly with random loader latencies. Land on C, show B's data. Reproduce it deterministically — if your reproduction is "click fast," you have observed it, not reproduced it.
- Back during load. Press back while a loader is pending. Where does the state end up?
- Redirect loop. A→B→A. What does the user see, and what should they see?
- Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
- Double submit. Two rapid clicks on a link with a side-effecting loader.
For (1), implement three fixes — AbortController, a navigation sequence number, and
render-time guarding — then rank them. Ask which is correct when the loader has a server side
effect. That question is the seam into fw-09.
3.5 Deep dive: navigation is a state machine with cancellation
Almost every hard router bug is a concurrency bug. Model navigation explicitly:
idle ──navigate──► loading ──resolved──► committing ──► idle
│ │
│ └──superseded──► discarded
└──error──────────► error route
The transitions that produce bugs:
- superseded — a second navigation starts while the first is loading. The first must be cancelled and must not commit.
- back during load —
popstatearrives mid-navigation. Which wins, and what is the URL now? - redirect during load — a loader returns a redirect; the original navigation must not commit.
- error during load — which boundary catches it, and does the URL change?
The invariant to name: only the most recent navigation may commit. Every stale-data bug in a router is a violation of it, and stating it in those terms turns four separate bugs into one.
The three fixes, ranked
| Fix | Mechanism | Stops the work? | Correct with server side effects? |
|---|---|---|---|
| Render-time guard | compare response's URL to current | no | no — the effect happened |
| Sequence number | ignore responses with a stale id | no | no — same |
AbortController | signal cancellation to the request | yes, best-effort | closest — the server may still have acted |
The ranking is: AbortController plus a sequence-number guard. Cancellation is best-effort —
the request may already have reached the server — so you still need to ignore late responses.
The question that separates a mid-level from a senior answer: what if the loader has a server
side effect? Then no client-side fix is sufficient. You need idempotency keys, or the operation
must not be in a loader at all. That is the seam into fw-09, and it is a genuinely
architectural answer rather than a code fix.
3.6 Deep dive: the History API's sharp edges
| Reality | Consequence |
|---|---|
popstate does not say direction | you cannot tell back from forward without tracking indices yourself |
pushState does not fire popstate | your own navigations need explicit handling |
| History entries have a state object with a size limit | do not store your app state there |
| You cannot read the history stack | no "can I go back?" without your own bookkeeping |
| You cannot cancel a back navigation | which is why unsaved-changes guards are hard |
That last row is a deliberate platform decision: allowing pages to trap the back button would be
user-hostile. So beforeunload is limited to a browser-controlled dialog, and in-app blocking
only works for navigations your router controls.
The Navigation API is the modern answer — it exposes an intercept-able navigation event with real cancellation — and knowing why it exists (the table above) is more valuable than knowing its method names.
Scroll restoration
history.scrollRestoration = 'manual' hands you the job. Doing it correctly requires restoring
after the content that determines page height exists, which in an async-loading app means
after data resolves — and possibly after images load. Restoring too early scrolls to a position
that does not exist yet and silently clamps to the bottom.
This is the same anchoring problem as fw-10's virtualized list and the browser's own scroll
anchoring (bi-08). Three encounters, one problem: you cannot restore a position in a document
whose size you do not yet know.
4. Trade-offs
Commit-then-load vs load-then-commit. Showing the new route immediately with a spinner feels faster and can flash; waiting feels slower and is consistent. Frameworks disagree, and the right answer is product-specific.
Router-owned data vs component-owned. Loaders remove waterfalls and couple data to routes.
Core terminology
| Term | Definition |
|---|---|
| Route matching | Selecting a route from a URL; static, params, wildcards, ranked by specificity |
| Matched-route chain | The nested set of routes a URL resolves to, outermost first |
| Loader | Per-route async data fetch, resolved before the transition commits |
| Commit | The moment the new route becomes the displayed one |
| Superseded navigation | One started while another was loading; must not commit |
| Sequence number | Monotonic counter incremented at dispatch; the only reliable ordering source |
AbortController / signal | Best-effort cancellation — the request may already have reached the server |
| Render-time guard | Comparing a response's URL to the current one before using it |
| Idempotency key | Server-side deduplication token; the only fix when the effect already happened |
popstate | Fires on history traversal; does not report direction, and does not fire for pushState |
| History state object | Per-entry state with a size limit; not a place for application state |
| Scroll restoration | Returning to a prior scroll offset; history.scrollRestoration = 'manual' hands you the job |
| Navigation blocking | Preventing a transition (unsaved changes); impossible for browser-driven back |
| Navigation API | The modern replacement exposing an interceptable, genuinely cancellable event |
| Commit-then-load | Show the new route immediately with a spinner |
| Load-then-commit | Wait for data, then swap — consistent, feels slower |
Mental models
Name the invariant and four bugs become one. Only the most recent navigation may commit. Stale data, back-during-load, redirect-during-load and double-submit are all the same violation. This is the single most transferable move in the module: when several bugs share a root, find the sentence they all violate before writing any fix.
Order by a monotonic counter you control, not by time. Wall clock is not trustworthy across
tabs; server timestamps have skew and insufficient resolution; arrival order is exactly the thing
that is wrong. A sequence number incremented at dispatch is one integer and it is exactly correct —
the same conclusion fw-09 reaches independently, which is why the two modules are adjacent.
Cancellation and guarding are complementary, not alternatives.
| Fix | Mechanism | Stops the work? | Correct with server side effects? |
|---|---|---|---|
| Render-time guard | compare response's URL to current | no | no — the effect happened |
| Sequence number | ignore responses with a stale id | no | no — same |
AbortController | signal cancellation to the request | yes, best-effort | closest — the server may still have acted |
The answer is AbortController plus a sequence-number guard. Cancellation is best-effort, so
you still need to ignore late responses.
"You cannot restore a position in a document whose size you do not yet know." Scroll restoration
must happen after the content determining page height exists — which in an async app means after
data resolves, and possibly after images load. Restoring too early clamps to the bottom silently.
This is the same anchoring problem as fw-10's virtualized list and the browser's own scroll
anchoring (bi-08). Three encounters, one problem.
A reproduction you cannot control is an observation. "Click fast" is not a repro. Deterministic latency injection is what turns a race into a test, and it is the difference between fixing the bug and believing you fixed it.
Common misconceptions
-
"Routing is URL matching." Matching is the easy part. The hard part is a cancellable state machine over interruptible async transitions.
-
"
popstatetells you the user went back." It does not report direction. Distinguishing back from forward requires tracking indices in history state yourself. -
"
pushStatefirespopstate." It does not. Your own navigations need explicit handling — a common source of "the router didn't notice." -
"You can prevent the back button." You cannot cancel a browser-driven back navigation, by design.
beforeunloadgets you a browser-controlled dialog and nothing more. -
"A timestamp fixes last-write-wins." Client clocks differ across tabs, server clocks have skew, and arrival order is the problem itself. Use a monotonic counter you control.
-
"
AbortControllerfixes the stale-data race." It stops the work, best-effort. Late responses can still arrive, so you still need a guard — and if the loader had a server side effect, neither is sufficient. -
"Store router state in the history state object." It has a size limit and is serialised on every entry. It holds a scroll position and an index, not your application state.
-
"Loaders are strictly better than component-level fetching." They eliminate waterfalls and couple data to URL structure. That coupling is wrong when the same data is needed off-route or when the URL shape is a presentation decision.
Interview talking points
- "Every hard router bug I've seen is a concurrency bug, and they collapse into one invariant: only the most recent navigation may commit. Stale loader data, back-during-load, redirect-during-load and double-submit are all violations of that sentence. Naming it turns four fixes into one."
- "For the stale-loader race I'd use
AbortControllerplus a sequence-number guard, not either alone. Cancellation is best-effort — the request may already have reached the server — so you still need to ignore late responses. And I'd order by a monotonic counter I control, never by wall clock or arrival." - "The question that actually separates answers is what happens when the loader has a server side effect. Then no client-side fix is sufficient and you need idempotency keys, or the operation shouldn't be in a loader. That's an architectural answer rather than a code fix."
- "
popstatedoesn't tell you direction and doesn't fire for your ownpushState, and you can't cancel a back navigation at all. That last one is a deliberate platform decision — trapping the back button would be user-hostile — which is the context that makes the Navigation API make sense." - "Scroll restoration is the same problem as a virtualized list's anchor drift and the browser's own scroll anchoring: you can't restore a position in a document whose size you don't yet know. Restoring before the data resolves silently clamps to the bottom."
- "If someone's reproduction is 'click fast,' they've observed the bug, not reproduced it. I'd want deterministic latency injection before I'd believe a fix."
5. Principal Engineer Review
- Name the invariant violated by a stale loader overwrite, and defend your preferred fix including the side-effecting case.
popstatedoes not distinguish back from forward. What does that force routers to build?- Loaders eliminate request waterfalls but couple data to URL structure. When is that wrong?
- Design navigation blocking for unsaved changes that works with the back button. What can you not do, and why is that a platform decision?
- Compare commit-then-load with load-then-commit on perceived performance and on correctness.
Connections to other modules
fw-02— prerequisite; the router commits into your renderer.fw-09— the direct continuation. The sequence-number conclusion is reached independently there, and the server-side-effect question is the seam between the modules.fw-10— scroll anchoring, again: the same "you cannot restore a position in a document whose size you do not know" problem, at row granularity.bi-08— the browser's own scroll anchoring; the third encounter with the same problem, and the one that shows why the platform built it in.fe-01— "completion order is not issue order" is a scheduling statement before it is a routing one.fe-04— progressive enhancement: what the user gets when a client-side router's bundle fails.