
A Quiet Departure from window.history
We have been fighting the limitations of the History API for a decade, but a more ergonomic way to handle routing has finally landed in the browser.
Why is it that after nearly two decades of building single-page applications, we are still monkey-patching window.history.pushState just to find out when a user clicks a link?
It’s a strange reality. We’ve built incredibly complex frameworks like React, Vue, and Svelte, yet the foundation they sit upon—the browser’s History API—was never actually designed for the way we build web apps today. It was designed for a document-based web where "navigation" meant fetching a brand-new file from a server and starting from scratch.
When we moved toward SPAs, we hijacked that mechanism. We started using pushState to change the URL without a reload, and then we spent years fighting the fallout: broken back buttons, messy state synchronization, and the nightmare of trying to determine if a navigation was triggered by a user's click, a script, or a browser gesture.
But there is a quiet shift happening. A new API has landed in Chromium-based browsers that finally treats navigation as a first-class citizen of the modern web. It’s called the Navigation API, and it’s the upgrade we should have had ten years ago.
The Clunkiness of the Status Quo
To understand why the new API matters, we have to look at how broken the old one is. If you want to react to URL changes in a standard web app today, you usually reach for the popstate event.
There’s just one problem: popstate only fires when the user clicks the "Back" or "Forward" button. It doesn't fire when you call history.pushState() or history.replaceState().
If you’re writing a router, you end up doing something like this:
// The "hack" we've all accepted as normal
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
window.dispatchEvent(new Event('pushstate'));
window.dispatchEvent(new Event('locationchange'));
};
window.addEventListener('popstate', () => {
window.dispatchEvent(new Event('locationchange'));
});
window.addEventListener('locationchange', () => {
console.log('The URL changed! Finally.');
});This is absurd. We are essentially wrapping the browser's own methods to notify ourselves that we just called them. On top of that, history.state is a bit of a black box. It’s serialized and stored, but accessing it and keeping it in sync with your application’s actual UI state is often a race condition waiting to happen.
Enter the Navigation API
The Navigation API (formerly known as the App History API) gives us a centralized way to intercept, manage, and even cancel navigations. Instead of listening to three different events and monkey-patching global objects, we have a single entry point: navigation.
The most powerful part of this new world is the navigate event. It catches *everything*. Whether the user clicked a link, hit the back button, or a script called navigation.navigate(), it all flows through one place.
Here is what a basic implementation looks like:
navigation.addEventListener('navigate', (event) => {
// Check if we can even intercept this navigation
// (e.g., it's not a cross-origin request)
if (!event.canIntercept || event.hashChange || event.downloadRequest) {
return;
}
const url = new URL(event.destination.url);
if (url.pathname === '/dashboard') {
event.intercept({
async handler() {
console.log('Navigating to dashboard...');
const data = await fetchDashboardData();
renderDashboard(data);
}
});
}
});The event.intercept() method is the star of the show. It tells the browser, "I've got this." It prevents the default browser behavior (which would be a full page reload) and lets you handle the transition yourself.
But it does more than just stop the reload. It provides a handler property that accepts a function returning a Promise. While that Promise is pending, the browser knows the navigation is "in progress."
Managing Async Transitions Gracefully
One of the biggest headaches in SPA routing is handling the "loading" state. When a user clicks a link, you usually want to show a progress bar or a spinner, fetch some data, and then swap the view.
With the old History API, you had to manually track if a navigation was pending. If a user clicked two links in rapid succession, you had to write complex logic to cancel the first fetch so it didn't overwrite the second one (the "out-of-order responses" problem).
The Navigation API handles this natively. If a new navigation starts while a previous intercept handler is still running, the API simply aborts the old one.
navigation.addEventListener('navigate', (event) => {
event.intercept({
async handler() {
// The browser automatically provides an AbortSignal
const signal = event.signal;
const response = await fetch(`/api/content${url.pathname}`, { signal });
const html = await response.text();
if (signal.aborted) return; // Clean up if navigation was cancelled
document.getElementById('app').innerHTML = html;
}
});
});The inclusion of event.signal is brilliant. It’s a standard AbortSignal tied to that specific navigation. If the user clicks "Back" before your fetch completes, the signal triggers, and your fetch is automatically cancelled. No more ghost requests.
State Management that Makes Sense
In the old world, history.state was often used to store small amounts of data, like scroll positions or simple IDs. But it was always a bit clunky. You had to provide the state when you called pushState, and if you wanted to update it later, you had to call replaceState.
The Navigation API introduces the concept of Navigation Entries. You can think of these as a snapshot of a specific point in the user’s journey.
// Accessing the current entry
const current = navigation.currentEntry;
console.log(current.url);
console.log(current.key); // A unique ID for this entry
console.log(current.id); // A unique ID for this specific "visit" to this entry
// Storing state
navigation.navigate('/settings', {
state: { theme: 'dark', preferences: { notifications: true } }
});
// Reading state later
const state = navigation.currentEntry.getState();
console.log(state.theme); // 'dark'The distinction between key and id is a subtle but important technical detail. A key stays the same even if you navigate away and come back via the back button. An id is unique to that specific instance. This makes it much easier to implement features like "scroll restoration" without getting confused by multiple history entries for the same URL.
What About the "Back" Button?
One of the most requested features for the web has always been a reliable way to intercept the back button—usually to show a "You have unsaved changes" dialog.
Previously, this was a mess of beforeunload (which only works for full page reloads) and pushing "dummy" entries onto the history stack to catch the popstate.
With the Navigation API, it becomes much cleaner. You can check the navigationType on the event.
navigation.addEventListener('navigate', (event) => {
if (event.navigationType === 'traverse' && isFormDirty()) {
if (!confirm('You have unsaved changes. Stay on page?')) {
event.preventDefault(); // Stop the navigation entirely
}
}
});Note that preventDefault() only works if the navigation is "cancelable." Most navigations are, including those triggered by the back/forward buttons, as long as they stay within the same document. This is a massive improvement for user experience in complex editor-style apps.
Practical Example: A Minimalist Router
To show how much cleaner this is, let's look at a "real-world" example. Imagine we want to build a tiny router that handles content swapping and updates the page title.
const routes = {
'/': () => '<h1>Home</h1><p>Welcome to the site.</p>',
'/about': () => '<h1>About</h1><p>We are building the future.</p>',
'/contact': () => '<h1>Contact</h1><p>Get in touch!</p>'
};
navigation.addEventListener('navigate', (event) => {
const url = new URL(event.destination.url);
const path = url.pathname;
if (routes[path]) {
event.intercept({
async handler() {
// Simulate a network delay
await new Promise(r => setTimeout(r, 200));
const content = routes[path]();
document.getElementById('main-content').innerHTML = content;
document.title = `My App - ${path === '/' ? 'Home' : path.slice(1)}`;
}
});
}
});
// To navigate programmatically
// navigation.navigate('/about');That’s it. No monkey-patching. No manual event dispatching. No complex state syncing. The browser handles the heavy lifting, and we just provide the logic for what should happen when a navigation occurs.
The Edge Cases: What to Watch For
As with any powerful new tool, there are things that will trip you up.
1. Same-Document vs. Cross-Document
The Navigation API is designed for same-document navigation. If the user clicks a link to a different domain, or if you call location.href = '...', the navigate event will fire, but you won't be able to call intercept(). Always check event.canIntercept.
2. The AbortSignal is Your Friend
If you use intercept(), the browser assumes you are handling the UI change. If your handler fails (throws an error), the navigation is marked as failed. You should use the event.signal to cancel any ongoing work to prevent "leaking" memory or logic from a navigation that no longer matters.
3. Scroll Restoration
The Navigation API handles scroll restoration by default, but if you are doing complex DOM manipulation inside your handler, you might want to control it manually. You can call event.scroll() at the exact moment your DOM is ready to tell the browser "Okay, now you can move the scroll position."
event.intercept({
async handler() {
const data = await fetchData();
renderDOM(data);
// Explicitly tell the browser to restore scroll now
event.scroll();
}
});Can You Use This Today?
The Navigation API is currently available in Chrome, Edge, and other Chromium browsers (v102+). Safari and Firefox have not yet implemented it, though there is significant interest and ongoing discussion in the standards groups.
Does that mean you shouldn't use it? Not necessarily.
This is a perfect candidate for progressive enhancement. You can check if the API exists and use it if it does, falling back to a traditional router (or just letting the browser do a full reload) if it doesn't.
if (window.navigation) {
// Use the shiny new Navigation API
navigation.addEventListener('navigate', handleNavigation);
} else {
// Fall back to old-school routing or do nothing
console.log('Navigation API not supported. Falling back.');
}There is also a polyfill available that brings much of this functionality to older browsers by wrapping the old History API. While it can’t make the old API perfect, it allows you to write your application code using the new, cleaner mental model.
The Mental Shift
The real value of the Navigation API isn't just that it saves us a few lines of code or a couple of hacks. It's about the shift in the mental model.
For the last decade, we’ve treated the URL as a pesky side effect of our application state. We’ve spent so much energy trying to keep the address bar in sync with our components.
The Navigation API flips that. It puts the URL and the user’s intent back at the center of the web experience. It treats every move—whether a back-button click, a link click, or a programmatic jump—as a single, unified event that we can observe, modify, or cancel.
It's a quiet departure from the clunky window.history we've grown to tolerate, but it's a departure that finally brings the browser's native capabilities in line with the way we actually build for the web. If you haven't played with it yet, open your Chrome dev tools and type navigation. It's a breath of fresh air.


