← blog
JavaScriptSeptember 1, 2026 · 5 min read

Your Router Doesn't Intercept Navigation. It Reacts To It.

Double-click a link during a slow route change and most SPA routers render two pages and settle on the wrong one — because they're listening for navigation after it already happened. The Navigation API lets you stop it before it starts.

Parsa Jiravand · Frontend engineer · building bestpractic
Your Router Doesn't Intercept Navigation. It Reacts To It.

Click a link. Before the new page finishes loading, click a different one. You'd expect the second click to win — that's what happens with a plain <a href> and a full page load. In a lot of single-page apps, it doesn't.

What actually happens: the first navigation starts fetching data. The second navigation starts too. Whichever fetch resolves last wins, regardless of which link you clicked last. For a second, the URL bar says one page and the rendered content says another. On a slow connection, "for a second" can be long enough for someone to screenshot it and file a bug titled "app shows wrong page."

Here's the part that surprises people: this isn't a bug in your router. It's the shape of the tool.

Every client-side router — the ones you've built by hand and the ones bundled into frameworks — is doing roughly this:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
document.addEventListener("click", (e) => { const link = e.target.closest("a"); if (!link || !isInternal(link)) return; e.preventDefault(); history.pushState({}, "", link.href); renderRoute(link.href); // fetch data, swap the view }); window.addEventListener("popstate", () => { renderRoute(location.href); // back/forward button });

Read that again and notice what's missing: nothing here knows about the previous renderRoute call while a new one starts. pushState already changed the URL by the time renderRoute begins — the navigation, as far as the browser's history is concerned, already happened. Your router isn't intercepting it. It's reacting to something already committed, then racing its own async work against whatever the last reaction was doing.

That's the double-click bug in one sentence: two renderRoute calls, no relationship between them, last one to finish wins. Add a canceled fetch and an AbortController to the mix and you can make it mostly go away — mature router libraries do exactly that. But you're patching a race condition from outside the event that caused it, because the click handler and the URL change were never one atomic thing to begin with.

The Navigation API exists specifically to close that gap. Instead of listening for popstate after a change, you listen for navigate on window.navigation — and that event fires before the browser commits to the new URL, for every kind of navigation: link clicks, back/forward, history.pushState, even form submissions.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
navigation.addEventListener("navigate", (event) => { if (!event.canIntercept) return; // e.g. cross-origin navigations can't be const url = new URL(event.destination.url); event.intercept({ async handler() { await renderRoute(url); // the browser waits on this }, }); });

intercept() is the piece popstate never had: it hands the browser a promise and the browser waits on it before treating the navigation as finished — no spinner state you have to fake, no separate "is this route still current" flag to check by hand. And because every navigate event carries its own event.destination, a second click firing a second navigate event doesn't need to guess whether an earlier one is still in flight — you can call event.signal (an AbortSignal the browser aborts automatically when a newer navigation supersedes this one) inside your handler and pass it straight to fetch:

JavaScript
1
2
3
4
5
6
event.intercept({ async handler() { const data = await fetch(url, { signal: event.signal }).then(r => r.json()); render(data); }, });

Click a second link before the first handler resolves, and the browser aborts the first navigation's signal for you. The stale fetch throws, you skip rendering it, and there's no manual bookkeeping to get wrong — the race condition isn't patched, it's structurally not possible to hit.

A few things fall out of treating navigation as one interceptable event instead of a click handler plus a popstate listener bolted on the side:

  • One event for everything. Link clicks, back/forward, and programmatic navigation.navigate() calls all go through the same navigate event — you're not maintaining a click handler and a separate popstate handler that have to agree with each other.
  • Cancellation that's actually cancellation. event.preventDefault() on navigate stops the navigation before the URL changes, not after — useful for "you have unsaved changes" guards that used to require faking the URL back with another pushState call.
  • Real navigation state. navigation.currentEntry, navigation.entries(), and events like navigatesuccess / navigateerror give you a queryable list of history entries with keys and state, instead of history.length (a number that tells you nothing about what's in the stack).

None of this replaces routing logic — you still decide what URL maps to what view. It replaces the part where the browser's actual navigation and your app's idea of navigation are two loosely-synced systems held together by preventDefault and hope.

The Navigation API has shipped in Chrome and Edge since 2022. Safari and Firefox don't have it yet as of this writing, which is the honest caveat: this isn't a drop-in replacement for your router today, and any production use needs a fallback path (or a router library that already detects and uses it under the hood, falling back to pushState where it's missing). Check current support before reaching for it directly in anything beyond a Chromium-only tool.

Runs right in your browser — poke at it and watch the concept react live.

popstate tells you a navigation happened. navigate tells you one is about to, and lets you decide what "happened" even means. That difference is the whole reason the double-click race exists in the first place — and the whole reason it stops existing once the browser is holding the promise instead of you holding a flag.

Next time your router's navigation logic starts growing isNavigating booleans and manual AbortController bookkeeping, that's usually this gap showing through. Have you hit the double-navigation race in production, or did your router already paper over it? I'd like to know which libraries got this right early.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.