JavaScript Navigation API Table

PieceWhat it doesField note
window.navigationThe router objectone per window; feature-detect via navigation in window - Chromium 102+, no Safari/Firefox yet
navigate eventThe choke pointfires before EVERY same-origin navigation - links, forms, location.assign, back/forward - uniformly
e.intercept({handler})The SPA takeoverDOM swap in the handler; history commits when the promise resolves - unhandled rejection rolls back to the old URL
e.navigationTypeThe kind switchpush / replace / reload / traverse - traverse must restore from cache, not re-fetch; one-size handlers are the classic bug
currentEntry / entries()The queue viewentry.key survives reloads, entry.id does not - address history positions without parallel URL-keyed maps
entry.getState()The per-entry stateserializable JSON that travels with traversals - scroll, filters, form drafts ride inside history
e.formData + preventDefaultThe form gateGET and POST submissions arrive with formData attached - validate, transform, or veto unsaved-changes exits
scroll + focusResetThe defaultsintercept resets scroll and manages focus sanely - the browser enforces what hand-rolled routers always got wrong
Reference: the MDN Navigation API. The first single gate over every same-origin navigation - and a transaction model: the handler swap finishes, THEN the URL commits, which is the discipline pushState never had.
Bottom line: ship it as progressive enhancement, because the fallback is the web itself. Chromium users get instant transitions; everyone else gets normal full-page loads that already work - the API cannot be polyfilled (intercepting browser navigation is a privilege), so never let the intercept path be the only path. Write push and traverse as different code paths, restore traversals from cache with entry.getState(), and pass e.signal to fetch so a fast second click aborts the stale first transition.
Related tools: the history table (the pushState era this replaces), the view transitions table (the animation half of the swap), the speculation rules table (the declarative speed-up feeding the same navigations), the service worker table (navigation responses at the network layer), and the postMessage table (routing messages the navigation API cannot see).

The Navigation API gives the browser a router: window.navigation fires a 'navigate' event before every same-origin navigation - clicked links, form submissions, location.assign, even back/forward - and one intercept() call turns any of them into a single-page-app transition. For the first time an SPA does not need per-link click handlers, popstate archaeology, or a pushState wrapper: the navigation itself is the hook, wherever it comes from.

Bottom line: interception is a transaction. In the navigate event you call e.intercept({handler}) - the handler performs the DOM swap, and the history entry commits when the handler's promise resolves; the browser shows the old page until then and can even roll back (an unhandled rejection cancels into the old location). That commit discipline is what the history API never had: no more half-rendered pages living at the wrong URL, and traversals (back/forward) get the same handler treatment with a signal for aborting stale transitions.

The honest part: this is a Chromium-only API (Chrome/Edge 102+, no Safari or Firefox shipping it), and unlike most missing APIs there is no meaningful polyfill - the entire value is intercepting navigations the browser would perform, which JavaScript cannot fake. But the fallback is free and total: browsers without window.navigation just navigate normally, multi-page-style. So the professional posture is progressive enhancement: full pages work everywhere; Chromium users get instant transitions via feature detection.

How to use

  1. Detect and gate: if (!('navigation' in window)) return; - then window.navigation.addEventListener('navigate', e => { if (e.canIntercept && !e.hashChange) ... }) - canIntercept rules out cross-origin and downloads; hash changes rarely need interception.
  2. Intercept the common path: e.intercept({handler: async () => { const html = await fetch(e.destination.url).then(r => r.text()); document.querySelector('main').innerHTML = parse(html); }}); - the URL bar updates when the handler resolves; the user never sees a committed-but-empty page.
  3. Type-switch on navigationType: e.navigationType is 'push' | 'replace' | 'reload' | 'traverse' - push renders fresh content, traverse must restore (read entry state, no fetch if you cached), reload re-runs init; a one-size handler is the classic bug.
  4. Carry state in entries: navigation.currentEntry.key and .id identify positions; entry.getState() holds per-entry JSON that survives traversals - store scroll positions, filters, and form drafts there instead of parallel maps keyed by URL.
  5. Route forms through the same gate: the navigate event fires for GET and POST form submissions with e.formData attached - validate or transform there, e.preventDefault() to refuse, and intercept for the SPA version. Control scroll with handler options (focusReset, implicit scroll behavior) instead of fighting restoration.

Frequently asked questions

How is this different from the History API I already use?

The History API is a store you push to; the Navigation API is a gate the browser holds. With history.pushState you wrap every link and form yourself, miss programmatic navigations (location.assign, autofill-driven form GETs), and guess on popstate what the user did. The navigate event fires for all same-origin navigation sources uniformly - anchors, forms, script, traversals - with a destination URL, navigationType, formData and source element on the event. The second difference is commit semantics: pushState commits immediately (leaving you synchronizing render state with URL state by hand), while intercept() commits the history entry when your handler resolves - the browser owns the loading indicator, the back/forward queue consistency, and cancellation. Keep the History API for tiny URL tweaks (query param filters without interception); use Navigation when the routing itself should be owned by the platform.

What happens on back/forward (traverse) - and why is it the hard case?

Traversals deliver the same intercept treatment but demand restoration, not rendering: the user expects the previous state instantly, including scroll position and component state. The handler reads navigation.currentEntry (the entry you are moving TO), pulls its .getState(), and rebuilds - ideally from cache, because a fetch inside a traverse handler makes the back button feel network-slow. Two supporting pieces: each entry has a stable .key and a unique .id (key survives page reloads, id does not), and e.signal aborts your in-flight transition when the user navigates again mid-flight - pass it to fetch's AbortSignal and stale renders die quietly. The discipline that makes SPAs feel native: write push and traverse as different code paths, never let a traverse hit the network first, and test the fast-clicker who goes back twice mid-transition.

When should I preventDefault instead of intercepting?

preventDefault is the veto; intercept is the takeover. Veto when navigation should not happen at all: an unsaved-changes guard on a form (with a confirm UI inside the event, since the event is synchronous), a download link your page handles itself, a POST you reject for a stale CSRF token. Intercept when navigation should happen but differently (render in-page). Both can combine: preventDefault on the default behavior and drive your own transition for edge cases - but prefer intercept + rejected handler for refusals too, because an unhandled rejection in the handler rolls the browser back to the old URL cleanly, whereas preventDefault leaves the old page frozen with no explanation unless you show one. Note the boundaries where neither runs: cross-origin navigations, other-origin iframes, and the very first page load are all un-interceptable (canIntercept false) - the browser does not let a page gate departures it did not invite.

Should I build on it today if Safari and Firefox do not ship it?

Yes, as progressive enhancement - and the arithmetic is in its favor. The fallback is not a degraded experience; it is the web's default: full page loads that work perfectly in every engine. Your Chromium users (the majority on desktop) get instant same-origin transitions, form-aware routing, and stateful traversals for the cost of one feature-detect branch. What you should NOT do is make the SPA-style behavior the only path - because the API cannot be polyfilled (intercepting browser navigation is a privilege, not a trick), users on Safari/Firefox would get a broken half-router where other APIs merely get a slower one. The mature shape: server-rendered or static pages as the foundation, intercept() as the accelerator, and a transition implementation shared by both paths (the DOM-swap function is the same code either way). When Firefox or Safari eventually ship, the enhancement branch lights up with zero further work.

Related tools