Agustin Barrientos
All posts

Building a Router the Back Button Can't Break

Want more of these in your Google results?

Some routers have a bug that shows up if you press Back before a page finishes loading.

I created a small inbox demo where you can reproduce the issue. The network menu is set to “Slow” so you have a couple of seconds before a message you haven’t opened loads.

Do this:

  1. Click Invoice September and wait for it to finish loading.
  2. Then click Concert October and press the Back button in the demo’s window before it loads.
router.ts
// index.ts
import { fetchMessage } from '../stand-in-server';
import type { InboxView } from '../inbox-view/types';

/**
 * Starts the smallest router the Navigation API allows.
 *
 * One `navigate` listener gets every navigation in the document, whether it comes from a link, a form, Back, Forward, or a script.
 * The listener takes over the ones that stay on the inbox's own page, loads the message the new URL names, and renders it.
 * It never stops a request.
 * A message the user already left can still finish loading and render under a newer URL.
 * Without the Navigation API it does nothing, and every link loads as a normal page.
 *
 * @param view - The inbox that draws what the URL names.
 * @returns A function that stops the router.
 */
export function startRouter( view: InboxView ): () => void {
	// Check if the Navigation API is available. If not, return a no-op function.
	if ( ! ( 'navigation' in window ) ) {
		return () => {};
	}

	// Remember the inbox's own path, to tell its links apart from links to other pages.
	const inboxPath = location.pathname;

	// Runs on every navigation, whether from a link, a form, Back, Forward, or a script.
	const onNavigate = ( event: NavigateEvent ): void => {
		// Leave navigations the page can't take over, jumps to a #fragment, and downloads to the browser.
		if ( ! event.canIntercept || event.hashChange || event.downloadRequest !== null ) {
			return;
		}
		// Other pages on the same site load as usual, because the inbox only handles its own page.
		const url = new URL( event.destination.url );
		if ( url.pathname !== inboxPath ) {
			return;
		}
		// The message id from ?message=, or null on the inbox itself.
		const id = url.searchParams.get( 'message' );

		// Take the navigation over.
		// The browser changes the URL without loading a page, then runs handler().
		event.intercept( {
			async handler() {
				// The inbox needs no request.
				if ( id === null ) {
					view.showInbox();
					document.title = 'Inbox';
					return;
				}

				// Load the message, draw it, and name the tab after it.
				const message = await fetchMessage( id );
				view.showMessage( message );
				document.title = message.subject;
			},
		} );
	};

	navigation.addEventListener( 'navigate', onNavigate );

	// Hand back a function that stops the router.
	return () => navigation.removeEventListener( 'navigate', onNavigate );
}

The demo keeps messages you’ve already opened in memory. In other words, when you navigate back to a message, you’ll notice it loads right away. If you want to see the bug again, reload the page to clear the cache.

When you pressed Back, the invoice came back immediately, but the concert was still loading, and when it finished, the router drew it anyway.

So now the address bar has the invoice’s URL and the page shows the concert. It stays like that until you go somewhere else. That also means you get the invoice if you reload. Same if you share the link or bookmark it.

That’s the bug this article is about.

These demos run on the Navigation API, which is supported by all the major browser engines.

Through it, the page gets a navigate event for any navigation, including Back and Forward. Routers listen for this event and take the navigation over. From there, the browser skips the page load and lets the router draw the content itself.

Even in the middle of a load, the URL has to describe what’s on screen. Otherwise the page is in a mixed state, where the URL points to one message and the screen holds another, like in the demo above. To find one in any router, press Back before a load finishes.

Every navigation goes through one event

Routers built on the older History API caught link clicks themselves and changed the URL with history.pushState(). They listened for popstate to catch Back and Forward. Anything that didn’t start from a link they were watching, like a form submission, loaded a full page.

Now window.navigation fires a navigate event for any of them. One listener is enough.

Some of them should stay with the browser. Links to other sites have to, because a page can’t put its content under another site’s address. Jumps to a heading further down the page and links that download a file work fine without a router. And a link to /about or any other page of the site should load that page. If the router took it over, it would draw the inbox under /about.

Four values on the navigate event separate those cases:

  • canIntercept is false if the page isn’t allowed to take the navigation over, for a link to another site or for Back to a page outside the app.
  • hashChange is true for a jump to a #fragment on the same page.
  • downloadRequest holds a file name for a link with a download attribute, and it’s null otherwise.
  • destination.url holds the target URL, and the router compares its path with the inbox page’s path.

Below is a minimal router that tests those four values and leaves the matching navigations to the browser. The inbox it draws into is passed in as view.

router.js
// router.js
function startRouter( view ) {
	// Check if the Navigation API is available. If not, return a no-op function.
	if ( ! ( 'navigation' in window ) ) {
		return () => {};
	}

	// Remember the inbox's own path, to tell its links apart from links to other pages.
	const inboxPath = location.pathname;

	// Runs on every navigation, whether from a link, a form, Back, Forward, or a script.
	const onNavigate = ( event ) => {
		// Leave navigations the page can't take over, jumps to a #fragment, and downloads to the browser.
		if (
			! event.canIntercept
			|| event.hashChange
			|| event.downloadRequest !== null
		) {
			return;
		}

		// Other pages on the same site load as usual, because the inbox only handles its own page.
		const url = new URL( event.destination.url );
		if ( url.pathname !== inboxPath ) {
			return;
		}

		// ...
	};

	navigation.addEventListener( 'navigate', onNavigate );

	// Hand back a function that stops the router.
	return () => navigation.removeEventListener( 'navigate', onNavigate );
}

The router handles everything that passes those checks. It gets the message ID from the URL and takes the navigation over by calling intercept() on the event.

At that point the browser updates the URL without loading a page, and a click adds an entry to the history. The spec names this step committing the navigation. Next, the browser calls the handler function the router passed to intercept(). Here it loads the message with fetchMessage() (the demos’ stand-in for a request to a server) and renders it.

js
// The message id from ?message=, or null on the inbox itself.
const id = url.searchParams.get( 'message' );

// Take the navigation over.
// The browser changes the URL without loading a page, then runs handler().
event.intercept( {
	async handler() {
		// The inbox needs no request.
		if ( id === null ) {
			view.showInbox();
			document.title = 'Inbox';
			return;
		}

		// Load the message, draw it, and name the tab after it.
		const message = await fetchMessage( id );
		view.showMessage( message );
		document.title = message.subject;
	}
} );

The handler is async, and the navigation lasts until its promise resolves or rejects. Chrome’s loading indicator spins throughout, and its stop button remains available, according to its guide to the API.

That’s the whole router from the first demo. MDN builds the example on its intercept() page this way.

And neither the first demo nor the one in MDN’s example stops a request after the user moves on.

Stopping a load the user left

In the first demo, your Back press started a new navigation to the invoice. Whenever another navigation starts, the browser cancels the one in progress. It canceled the one to the concert.

Nothing in the handler checked for that so once it loaded, it just drew the concert anyway. It should have obviously stopped instead.

To do that, however, the handler needs to know that its navigation was canceled. It can find out through event.signal. It’s an AbortSignal, the kind of object fetch() takes to cancel a request. Its aborted property turns true as the browser cancels the navigation, whether another navigation started or you pressed the stop button.

An example in the HTML spec hands the signal to the function that loads the content. Below, the router passes it to fetchMessage() and checks it a second time just ahead of drawing.

js
// With the signal, the request stops if the browser cancels the navigation.
const message = await fetchMessage( id, { signal: event.signal } );

// The navigation was canceled while the request ran. Draw nothing.
if ( event.signal.aborted ) {
	return;
}

view.showMessage( message );
document.title = message.subject;

fetchMessage() rejects with an AbortError if the signal aborts, like fetch() does with a real request.

That second check covers any step that doesn’t take a signal, for example a library call or a cache lookup between the request and the screen.

Repeat the earlier steps in the second demo.

router.ts
// index.ts
import { fetchMessage } from '../stand-in-server';
import type { InboxView } from '../inbox-view/types';

/**
 * Starts the smallest router, plus the one change that stops a load the user left.
 *
 * The browser aborts `event.signal` as soon as another navigation starts or the user presses Stop.
 * The router passes it to the request, which stops the request, and checks it again before rendering in case the response was already on its way.
 * The URL still changes the moment a link is clicked, while the old message stays on screen until the new one has loaded.
 * Without the Navigation API it does nothing, and every link loads as a normal page.
 *
 * @param view - The inbox that draws what the URL names.
 * @returns A function that stops the router.
 */
export function startRouter( view: InboxView ): () => void {
	// Check if the Navigation API is available. If not, return a no-op function.
	if ( ! ( 'navigation' in window ) ) {
		return () => {};
	}

	// Remember the inbox's own path, to tell its links apart from links to other pages.
	const inboxPath = location.pathname;

	// Runs on every navigation, whether from a link, a form, Back, Forward, or a script.
	const onNavigate = ( event: NavigateEvent ): void => {
		// Leave navigations the page can't take over, jumps to a #fragment, and downloads to the browser.
		if ( ! event.canIntercept || event.hashChange || event.downloadRequest !== null ) {
			return;
		}
		// Other pages on the same site load as usual, because the inbox only handles its own page.
		const url = new URL( event.destination.url );
		if ( url.pathname !== inboxPath ) {
			return;
		}
		// The message id from ?message=, or null on the inbox itself.
		const id = url.searchParams.get( 'message' );

		// Take the navigation over.
		// The browser changes the URL without loading a page, then runs handler().
		event.intercept( {
			async handler() {
				// The inbox needs no request.
				if ( id === null ) {
					view.showInbox();
					document.title = 'Inbox';
					return;
				}

				// With the signal, the request stops if the browser cancels the navigation.
				const message = await fetchMessage( id, { signal: event.signal } );

				// The navigation was canceled while the request ran. Draw nothing.
				if ( event.signal.aborted ) {
					return;
				}

				view.showMessage( message );
				document.title = message.subject;
			},
		} );
	};

	navigation.addEventListener( 'navigate', onNavigate );

	// Hand back a function that stops the router.
	return () => navigation.removeEventListener( 'navigate', onNavigate );
}

This time the concert never shows up.

Something looks off, though. Your click on the concert changes the address bar at once, but the invoice stays on screen for another two and a half seconds. In those seconds, the URL points to a message that hasn’t appeared yet.

That’s still a mixed state, just a shorter one.

When the URL should change

Of the two ways out, the simpler one is to switch right away. At the click, the URL changes and a loading view for the chosen message fills the screen. That’s how intercept() behaves without extra options. Since the browser commits the navigation ahead of calling handler, the loading view is the first thing the handler draws.

The other way is to switch when ready, keeping the old URL and the old message up while the next one loads. intercept() accepts a second function for that, precommitHandler. Its promise must resolve before the navigation commits and handler runs. That way the URL and the page change together.

precommitHandler comes with two conditions.

First, a page with a precommitHandler can delay or stop a navigation, and browsers allow that for navigations the page could cancel anyway. Those navigations have the event’s cancelable set to true, and intercept() throws if handed a precommitHandler for one where the value is false. Link clicks and form submissions are always cancelable, as are navigation.navigate() calls.

Back and Forward presses within one document are cancelable in a top-level page. Presses of the browser’s own Back and Forward buttons also depend on an earlier click or key press in the page. The spec requires that interaction so pages can’t make going back harder. Inside an iframe, no Back press is cancelable.

Second, the browser must support precommitHandler. Detection works by testing window for NavigationPrecommitController, an object that browsers without precommitHandler don’t have.

Browser supportintercept() with precommitHandler73% of users
Chrome141
Edge141
Firefox147
SafariNo
Safari iOSNo
Chrome Android141

Browser data updated September 24, 2026

Both designs reuse the drawing code from the first router, now in a shared render() function that displays the inbox or the message and sets the title.

js
// Switch when ready where the navigation is cancelable and precommitHandler exists.
if ( event.cancelable && 'NavigationPrecommitController' in window ) {
	let message = null;
	event.intercept( {
		// Runs before the URL changes. The navigation waits for its promise.
		async precommitHandler() {
			// The inbox needs no request.
			if ( id === null ) {
				return;
			}

			// A failed request becomes null, and render() then draws the error view.
			message = await fetchMessage( id, { signal: event.signal } ).catch( () => null );
		},
		// Runs once the URL has changed.
		handler() {
			render( view, id, message );
		}
	} );
	return;
}

Here precommitHandler loads the message and handler puts it on the page. On a failed request, .catch() returns null and render() displays an error view for the message.

A Back press in the middle of precommitHandler aborts the signal. Its .catch() swallows that abort as well, but a navigation with an aborted signal doesn’t commit. No history entry is created for the clicked message, and Back takes you from the message you’re looking at to the previous one.

If either check fails, the router falls back to switching right away.

js
// The URL already changed. Show the loading view while the message loads.
view.showLoading( id );

// On failure, message is null and render() draws the error view.
const message = await fetchMessage( id, { signal: event.signal } ).catch( () => null );

// Skip drawing if the user left during the load.
if ( event.signal.aborted ) {
	return;
}

render( view, id, message );

Meanwhile, the loading view displays the message’s subject from the list, with a few gray bars in place of the message. Like the router in the second demo, this handler gives up on an aborted signal.

Picking a design

You’ll find a switch for the two designs in the next demo’s toolbar.

With “Switch right away” picked, clicking a message updates the address bar and brings up a loading view with its subject. Under “Switch when ready”, the tab’s spinner turns while the old message stays up. Try the window’s Back button mid-load in that mode. Here in the article the demo runs in an iframe, where Back always switches right away. Without precommitHandler, the second button is disabled.

router.ts
// index.ts
import { fetchMessage } from '../stand-in-server';
import type { Message } from '../stand-in-server/types';
import type { InboxView } from '../inbox-view/types';

/**
 * Draws what the URL names and names the page after it.
 *
 * With no id it draws the inbox, and with an id but no message it draws the error view, because the request for that message failed.
 *
 * @param view - The inbox that draws it.
 * @param id - The message id from the URL, or `null` for the inbox itself.
 * @param message - The conversation that loaded, or `null` when there is none.
 */
function render( view: InboxView, id: string | null, message: Message | null ): void {
	if ( id === null ) {
		// No message in the URL means the inbox.
		view.showInbox();
		document.title = 'Inbox';
	} else if ( message === null ) {
		// An id without a message means its request failed.
		view.showError( id );
		document.title = 'Message not found';
	} else {
		view.showMessage( message );
		document.title = message.subject;
	}
}

/**
 * Starts a router that keeps the URL and the screen in agreement while a message loads.
 *
 * Where the browser lets it, the router switches when ready.
 * Its `precommitHandler` loads the message before the navigation commits.
 * Until then, the old message and the old URL stay on screen.
 * The option needs a cancelable `navigate` event, and `intercept()` throws an `InvalidStateError` when it comes with an event that can't be canceled.
 * That's why the router checks `event.cancelable` first.
 * A Back press is cancelable only in a top-level page, and a browser without `precommitHandler` has no `NavigationPrecommitController`.
 * Everywhere else the router switches right away.
 * The URL changes at once, and the page shows a loading view until the message is there.
 * In both designs, a request that fails ends in the error view for the URL it was for.
 * Because the browser never commits a navigation whose signal was aborted, the `.catch()` in `precommitHandler` can't put a load the user left on screen.
 * Without the Navigation API it does nothing, and every link loads as a normal page.
 *
 * @param view - The inbox that draws what the URL names.
 * @returns A function that stops the router.
 */
export function startRouter( view: InboxView ): () => void {
	// Check if the Navigation API is available. If not, return a no-op function.
	if ( ! ( 'navigation' in window ) ) {
		return () => {};
	}

	// Remember the inbox's own path, to tell its links apart from links to other pages.
	const inboxPath = location.pathname;

	// Runs on every navigation, whether from a link, a form, Back, Forward, or a script.
	const onNavigate = ( event: NavigateEvent ): void => {
		// Leave navigations the page can't take over, jumps to a #fragment, and downloads to the browser.
		if ( ! event.canIntercept || event.hashChange || event.downloadRequest !== null ) {
			return;
		}
		// Other pages on the same site load as usual, because the inbox only handles its own page.
		const url = new URL( event.destination.url );
		if ( url.pathname !== inboxPath ) {
			return;
		}
		// The message id from ?message=, or null on the inbox itself.
		const id = url.searchParams.get( 'message' );

		// Switch when ready where the navigation is cancelable and precommitHandler exists.
		if ( event.cancelable && 'NavigationPrecommitController' in window ) {
			let message: Message | null = null;
			event.intercept( {
				// Runs before the URL changes. The navigation waits for its promise.
				async precommitHandler() {
					// The inbox needs no request.
					if ( id === null ) {
						return;
					}

					// A failed request becomes null, and render() then draws the error view.
					message = await fetchMessage( id, { signal: event.signal } ).catch( () => null );
				},
				// Runs once the URL has changed.
				handler() {
					render( view, id, message );
				},
			} );
			return;
		}

		// Otherwise switch right away. The URL changes now, and a loading view stays up until the message arrives.
		event.intercept( {
			async handler() {
				// The inbox needs no request.
				if ( id === null ) {
					render( view, id, null );
					return;
				}

				// The URL already changed. Show the loading view while the message loads.
				view.showLoading( id );

				// On failure, message is null and render() draws the error view.
				const message = await fetchMessage( id, { signal: event.signal } ).catch( () => null );

				// Skip drawing if the user left during the load.
				if ( event.signal.aborted ) {
					return;
				}

				render( view, id, message );
			},
		} );
	};

	navigation.addEventListener( 'navigate', onNavigate );

	// Hand back a function that stops the router.
	return () => navigation.removeEventListener( 'navigate', onNavigate );
}

Open this demo in a separate tab to see Back held. Your browser’s buttons replace the drawn window in that tab.

Click two messages, then reload the page to empty the demo’s cache. Pick “Switch when ready” and press your browser’s Back button. Choosing it counts as the interaction that lets a page hold Back.

For two and a half seconds, the address bar and the page stay on the second message, and they return to the first one together.

Both designs keep the URL and the screen in agreement, but they handle the wait differently.

Switching right away gives people instant feedback for their click, and it works in any browser with the Navigation API, iframes included. It costs a loading view for every location, and a fast load turns that view into a flash.

The when-ready design needs no loading view and never draws a half-finished page, which matches how normal page loads behave. But nothing on the page signals progress during a slow load, and the right-away code ships as its fallback regardless.

Nielsen Norman Group puts the limit for an uninterrupted flow of thought at about one second, and says delays under it normally need no special feedback. That makes the when-ready design a good fit for loads that finish within a second, and slower loads are better off with a loading view.

Focus and scroll

Focus and the scroll position have to follow the navigation too.

Once the handler’s promise resolves, the browser resets focus to the first element with an autofocus attribute, or else to the page body. So does the starting point for the next Tab. Keyboard users land back at the top of the page with each message.

The router turns that reset off by passing focusReset: 'manual' to intercept() in both designs. It puts focus on the message’s heading itself, with a focusHeading() call at the end of render(). The heading carries tabindex="-1", which lets a script focus it without adding it to the Tab order.

js
function render( view, id, message ) {
	if ( id === null ) {
		// No message in the URL means the inbox.
		view.showInbox();
		document.title = 'Inbox';
	} else if ( message === null ) {
		// An id without a message means its request failed.
		view.showError( id );
		document.title = 'Message not found';
	} else {
		view.showMessage( message );
		document.title = message.subject;
	}

	// All three branches end here, with focus on the heading the view just drew.
	view.focusHeading();
}

focusHeading() waits for the heading to render and focuses it with { preventScroll: true }. Without preventScroll, the focus move would scroll the reading pane.

Screen readers announce a heading as it receives focus. Arrow keys and Space scroll the box that holds the focused element, and in this inbox that box is the message.

On a click, the browser scrolls the page to the top, or to the #fragment in the URL. Back and Forward restore the saved position after the handler’s promise resolves, and so does a reload, unless the promise rejects or the user scrolled in the meantime. A router that renders the top of the content first can run that scroll early with event.scroll().

This restores the scroll position of the page. But the reading pane in this inbox scrolls on its own, separate from the page.

According to the HTML spec, browsers should try to restore panes like that, and apps can’t assume any particular scroll position. Chromium, Firefox, and WebKit reset the pane to the top on Back.

Instead, the demo saves the pane’s position in the history entry. History entries can hold a little data for the page, called their state. The position goes into that state, and it comes back on your return to the entry.

Next to the router, a second navigate listener does that. At the start of each navigation, navigation.updateCurrentEntry() saves the pane’s position into the current entry’s state.

The event’s navigationType is 'traverse' for Back or Forward and 'reload' for a reload. On Back or Forward, the listener looks for the destination among the page’s history entries in navigation.entries(). It’s the entry whose key matches event.destination.key. The entry’s getState() returns its state. From the state, savedScrollTop() returns the saved position or 0 for an entry without one.

There’s a shortcut, event.destination.getState(), but WebKit returns nothing from it on Back. The lookup in navigation.entries() works in all three engines.

Reloads leave the pane in place, and clicks start it at the top. The listener keeps the position to go back to in restoreTo.

js
// Read what the page already stored in the entry it's leaving.
const state = navigation.currentEntry?.getState();

// Start from that data when it's an object, and from an empty object otherwise.
const saved = typeof state === 'object' && state !== null ? state : {};

// Add the pane's position to the entry the page is leaving, next to that data.
navigation.updateCurrentEntry( {
	state: {
		...saved,
		scrollTop: $scroller.scrollTop
	}
} );

// On Back or Forward, the pane returns to the position saved in the destination entry.
if ( event.navigationType === 'traverse' ) {
	// Find it by key, since WebKit's event.destination.getState() is empty here.
	const destination = navigation.entries().find( ( entry ) => entry.key === event.destination.key );
	restoreTo = savedScrollTop( destination?.getState() );
	return;
}

// A reload leaves the pane in place.
if ( event.navigationType === 'reload' ) {
	restoreTo = $scroller.scrollTop;
	return;
}

// A click, or any other navigation, starts the pane at the top.
restoreTo = 0;

The listener leaves the pane alone until the browser fires navigatesuccess, at the end of the navigation. Any earlier, the pane would be too short to scroll.

Jumps to a #fragment fire that event too. So the listener clears restoreTo whenever a navigation starts and skips those jumps, or the pane would return to its position from the previous navigation.

You can try focus and scroll in the last demo. It starts on Fast, with nothing in it that depends on a slow load. Its code panel has the router with the manual focus reset, plus the whole scroll listener.

Focus on the page body is invisible, so the window has a status line at the bottom that names the focused element.

Tab to Invoice September (Option+Tab in Safari) and press Enter. The status line shows “The page body”.

Then turn on “Focus the heading” and open Trip November with the keyboard. This time the heading gets focus. Press the Down arrow to scroll the thread.

For the scroll part, scroll down in the thread and click another message. Press the window’s Back button next. Whether the switch is on or off, the thread’s scroll position comes back.

router.ts
// index.ts
import { fetchMessage } from '../stand-in-server';
import type { Message } from '../stand-in-server/types';
import type { InboxView } from '../inbox-view/types';

/**
 * Draws what the URL names, names the page after it, and moves focus to its heading.
 *
 * With no id it draws the inbox, and with an id but no message it draws the error view, because the request for that message failed.
 * Focus goes to the heading so a keyboard user continues from the new content and a screen reader announces where they arrived.
 *
 * @param view - The inbox that draws it.
 * @param id - The message id from the URL, or `null` for the inbox itself.
 * @param message - The conversation that loaded, or `null` when there is none.
 */
function render( view: InboxView, id: string | null, message: Message | null ): void {
	if ( id === null ) {
		// No message in the URL means the inbox.
		view.showInbox();
		document.title = 'Inbox';
	} else if ( message === null ) {
		// An id without a message means its request failed.
		view.showError( id );
		document.title = 'Message not found';
	} else {
		view.showMessage( message );
		document.title = message.subject;
	}

	// All three branches end here, with focus on the heading the view just drew.
	view.focusHeading();
}

/**
 * Starts the router from the two designs, with focus handled by the router instead of the browser.
 *
 * By default the browser moves focus to the page body, or to the first `autofocus` element, once an intercepted navigation finishes.
 * `focusReset: 'manual'` turns that off, so focus stays where the router puts it, on the heading of the new content.
 * Everything else matches the two designs.
 * It switches when ready where the `navigate` event is cancelable and `precommitHandler` exists, and switches right away with a loading view everywhere else.
 * Without the Navigation API it does nothing, and every link loads as a normal page.
 *
 * @param view - The inbox that draws what the URL names.
 * @returns A function that stops the router.
 */
export function startRouter( view: InboxView ): () => void {
	// Check if the Navigation API is available. If not, return a no-op function.
	if ( ! ( 'navigation' in window ) ) {
		return () => {};
	}

	// Remember the inbox's own path, to tell its links apart from links to other pages.
	const inboxPath = location.pathname;

	// Runs on every navigation, whether from a link, a form, Back, Forward, or a script.
	const onNavigate = ( event: NavigateEvent ): void => {
		// Leave navigations the page can't take over, jumps to a #fragment, and downloads to the browser.
		if ( ! event.canIntercept || event.hashChange || event.downloadRequest !== null ) {
			return;
		}
		// Other pages on the same site load as usual, because the inbox only handles its own page.
		const url = new URL( event.destination.url );
		if ( url.pathname !== inboxPath ) {
			return;
		}
		// The message id from ?message=, or null on the inbox itself.
		const id = url.searchParams.get( 'message' );

		// Switch when ready where the navigation is cancelable and precommitHandler exists.
		if ( event.cancelable && 'NavigationPrecommitController' in window ) {
			let message: Message | null = null;
			event.intercept( {
				// Keep the browser from moving focus to the page body after the navigation.
				focusReset: 'manual',
				// Runs before the URL changes. The navigation waits for its promise.
				async precommitHandler() {
					// The inbox needs no request.
					if ( id === null ) {
						return;
					}

					// A failed request becomes null, and render() then draws the error view.
					message = await fetchMessage( id, { signal: event.signal } ).catch( () => null );
				},
				// Runs once the URL has changed.
				handler() {
					render( view, id, message );
				},
			} );
			return;
		}

		// Otherwise switch right away. The URL changes now, and a loading view stays up until the message arrives.
		event.intercept( {
			// Keep the browser from moving focus to the page body after the navigation.
			focusReset: 'manual',
			async handler() {
				// The inbox needs no request.
				if ( id === null ) {
					render( view, id, null );
					return;
				}

				// The URL already changed. Show the loading view while the message loads.
				view.showLoading( id );

				// On failure, message is null and render() draws the error view.
				const message = await fetchMessage( id, { signal: event.signal } ).catch( () => null );

				// Skip drawing if the user left during the load.
				if ( event.signal.aborted ) {
					return;
				}

				render( view, id, message );
			},
		} );
	};

	navigation.addEventListener( 'navigate', onNavigate );

	// Hand back a function that stops the router.
	return () => navigation.removeEventListener( 'navigate', onNavigate );
}

/**
 * Returns the scroll position saved in a history entry's state, if there is one.
 *
 * @param state - Whatever `getState()` returned for the entry.
 * @returns The saved position, or `0` for an entry that never saved one.
 */
function savedScrollTop( state: unknown ): number {
	if ( typeof state === 'object' && state !== null && 'scrollTop' in state && typeof state.scrollTop === 'number' ) {
		return state.scrollTop;
	}
	return 0;
}

/**
 * Saves the position of a box that scrolls inside the page in each history entry, and puts it back on Back, Forward, and reload.
 *
 * The browser restores the scroll position of the page itself after an intercepted Back, but not of a box that scrolls inside the page, like a reading pane.
 * So before every navigation the current position goes into the current entry's state with `navigation.updateCurrentEntry()`.
 * When a traversal finishes, the box scrolls to the position saved in the destination entry, a reload keeps the position it had, and any other navigation starts it at the top.
 * After a jump to a fragment, the box stays where the browser put it, even though `navigatesuccess` fires for that jump too.
 * It runs next to any router built on the `navigate` event, because it only listens and never calls `intercept()`.
 *
 * @param $scroller - The box whose position each entry keeps.
 * @returns A function that stops saving and restoring.
 */
export function rememberScroll( $scroller: HTMLElement ): () => void {
	if ( ! ( 'navigation' in window ) ) {
		return () => {};
	}
	let restoreTo: number | null = null;

	const onNavigate = ( event: NavigateEvent ): void => {
		// A new navigation drops the position the last one was going to restore.
		restoreTo = null;
		if ( ! event.canIntercept || event.hashChange ) {
			return;
		}
		// Read what the page already stored in the entry it's leaving.
		const state = navigation.currentEntry?.getState();

		// Start from that data when it's an object, and from an empty object otherwise.
		const saved = typeof state === 'object' && state !== null ? state : {};

		// Add the pane's position to the entry the page is leaving, next to that data.
		navigation.updateCurrentEntry( {
			state: {
				...saved,
				scrollTop: $scroller.scrollTop,
			},
		} );

		// On Back or Forward, the pane returns to the position saved in the destination entry.
		if ( event.navigationType === 'traverse' ) {
			// Find it by key, since WebKit's event.destination.getState() is empty here.
			const destination = navigation.entries().find( ( entry ) => entry.key === event.destination.key );
			restoreTo = savedScrollTop( destination?.getState() );
			return;
		}

		// A reload leaves the pane in place.
		if ( event.navigationType === 'reload' ) {
			restoreTo = $scroller.scrollTop;
			return;
		}

		// A click, or any other navigation, starts the pane at the top.
		restoreTo = 0;
	};

	// Scroll only once the router has drawn the new content, which is when the navigation succeeds.
	const onSuccess = (): void => {
		if ( restoreTo !== null ) {
			$scroller.scrollTop = restoreTo;
		}
	};

	navigation.addEventListener( 'navigate', onNavigate );
	navigation.addEventListener( 'navigatesuccess', onSuccess );
	return () => {
		navigation.removeEventListener( 'navigate', onNavigate );
		navigation.removeEventListener( 'navigatesuccess', onSuccess );
	};
}

Checking your own router

Run this test on your router with Chrome DevTools. Set the Network panel’s Throttling menu to a slow preset like 3G, open one item, click another, and press Back before the second one loads. Check that the address bar and the content agree. See whether the Network panel lists the abandoned request as canceled.

Add a live expression for document.activeElement in the Console to track focus.

Last, open an item from far down a long list and note the list’s scroll position on Back.

I ran that test on this article’s router and on four popular ones, with data loading set up the way their docs describe.

Table How five routers handle Back while a message is loading.
RouterWhen the URL changesURL and screen after Back mid-loadFocus after a navigationScroll after Back
Angular RouterWhen the data is inAgree, request canceledNot movedLost without its scroll option
React RouterWhen the data is inAgree, request keeps runningNot movedLost without its scroll component
TanStack RouterAt the click, old message still upAgree, request keeps runningNot movedCame back
Vue RouterAt the click, with a loading viewAbandoned message can render under the wrong URLNot movedLost, even with its scroll option
This article’s routerAt the click with a loading view, or when the data is inAgree, request canceledMoved to the headingCame back, pane included
Tested in Chromium on October 5, 2026, with Angular Router 22.2 (route resolvers), React Router 8.4 and TanStack Router 1.170 (route loaders), and Vue Router 5.3 (fetching after the navigation). Vue Router’s fetch-before-navigation pattern held the URL back only when a message opened from the list.

TanStack Router has a pending component for loads, and by default it appears a second in. React Router and TanStack Router leave the abandoned request running unless the loader hands request.signal or abortController.signal to fetch. Vue Router’s guide pattern needs the watcher’s cleanup function to abort the request and end the late render.

Angular Router restores scroll through its scroll option, withInMemoryScrolling. Set its scrollPositionRestoration to 'enabled'. React Router has its <ScrollRestoration /> component for that.

In Vue Router, scrollBehavior ran before the list was drawn again. The list’s position came back with the guide’s delayed scroll or with the list kept in <KeepAlive>.

None of the four puts focus on the content, which leaves the heading focus from the previous section to you.

A few ways this goes wrong

Failed requests need an error view. By the time the request fails, the right-away design has already put the clicked message’s URL in the address bar. An error view under that URL keeps the page consistent, because the old message or a spinner would leave it in a mixed state.

Under the when-ready design, a precommitHandler that rejects cancels the navigation, and the old message stays under its URL. They agree, but the click fails without any sign. This router catches the error, so the navigation commits and the error view appears under the URL of the failed message, like a browser’s error page.

Guards for unsaved edits can stop links and forms by calling event.preventDefault() in a navigate listener. Back is harder to stop, given the conditions on cancelable navigations above.

Every canceled press of the browser’s Back button uses up the earlier click or key press. The visitor’s next Back press goes through, provided they haven’t clicked or typed since. No navigate event fires for a closed tab or an address typed into the bar, and the guard covers those with a beforeunload listener.

The first page load doesn’t fire navigate either. Pages draw whatever their URL points to by themselves on that load, and your server must answer any URL the router creates.

Static hosting handles that with a fallback page or with query strings like the demos’ ?message=. Query-string links load as normal pages in a browser without the Navigation API.

Search boxes that put the query in the URL add a history entry per keystroke. Back would step through the query letter by letter, unless those navigations use navigation.navigate( url, { history: 'replace' } ) to replace the current entry.

What separates a senior from a junior here

  • Fast networks hide everything but the end of a navigation, and by then any design looks right. Throttle the network and press Back before the load ends.
  • Routers can switch when ready for links but switch right away for Back inside an iframe, or in a browser without precommitHandler. Check which design runs on which path and confirm the screen matches the URL everywhere.
  • Treat focus and scroll as part of the navigation, like the URL.

How I’d review a routing change

The question I ask about a pull request that touches routing is whether the URL, the content, focus, and scroll describe one place after any navigation, interrupted ones included. I test that in the browser ahead of reading any code, because code that looks fine can still leave a mixed state.

Late renders and URLs that point to the wrong message block the merge, because people reload and share URLs. Focus left on the body blocks it too, as keyboard users lose their place with every new message. I’d accept a follow-up for a box whose scroll position doesn’t come back, as long as the author mentions it in the pull request.

My comment would be something like “Message 2 still renders under message 1’s URL if I press Back before it loads.” My suggested fix would pass event.signal to the fetch and test it ahead of rendering.

With the Navigation API, the browser keeps the URL and the history in order. Routers own what the page draws during a navigation, plus focus and the scroll of inner panes.

For an app like this inbox, I’d switch right away with a loading view, since people see their click land and one version of the code runs everywhere. I’d keep switching when ready for quick navigations, like moving between the tabs of a settings page, with the right-away path behind it.

Want more of these in your Google results?