The exact problem: a reader spends ten minutes on a form, clicks a link in the navigation, and the page changes. Nothing warned them, and there is nothing to go back to.

Context and Prerequisites

The signal this depends on is described in dirty and pristine state tracking: a reliable answer to “does this form hold changes the reader would be upset to lose”. Without normalised dirty tracking the warning fires on a trailing space, and a warning that fires when nothing changed is one readers learn to dismiss.

Core Pattern: Two Different Departures

A reader can leave in two ways, and they need completely different handling.

/**
 * 1. Leaving the DOCUMENT — closing the tab, reloading, following an external
 *    link. The browser owns this dialog; you cannot word it or style it, and
 *    you must not try. Returning a value is the entire API.
 */
function onBeforeUnload(e: BeforeUnloadEvent): void {
  if (!isDirty()) return;              // no prompt when there is nothing to lose
  e.preventDefault();
  // Required by older browsers; the string is ignored by all current ones.
  e.returnValue = '';
}

/**
 * 2. Leaving the ROUTE — a client-side navigation within the same document.
 *    Here you own the dialog, so it can name what is unsaved and offer to save.
 */
function onBeforeRouteChange(to: string, proceed: () => void, cancel: () => void): void {
  if (!isDirty()) return proceed();
  showUnsavedDialog({
    onDiscard: proceed,
    onCancel: cancel,
    onSave: async () => { await save(); proceed(); },
  });
}

Conflating them produces the two classic bugs: registering beforeunload and expecting your own wording, or handling only the router and losing everything on a reload.

Two ways to leave, two completely different mechanisms Leaving the document — a tab close, a reload, an external link — is handled by the beforeunload event. The browser supplies the dialog, its wording cannot be changed, and it only appears at all if the reader has interacted with the page, which is a deliberate anti-abuse rule. Leaving the route — a client-side navigation — is handled by the router's own guard, where you supply the dialog, so it can name what is unsaved, offer to save and continue, and be made accessible. Both are needed: handling only the router loses work on a reload, and handling only beforeunload gives no useful options. leaving the document — beforeunload tab close, reload, external link the browser owns the wording needs a prior user gesture to fire two options: leave, or stay register only while dirty leaving the route — your guard any client-side navigation you own the wording three options: save, discard, stay can name what is unsaved must be focus-managed and announced You need both. Only the router guard loses work on a reload; only beforeunload gives the reader no way to save. Which departures each mechanism can see Closing the tab, reloading and following an external link are all document departures, visible to beforeunload and invisible to a router guard. A client-side navigation is the reverse: the router sees it and beforeunload does not fire at all. Pressing the browser back button after a client-side navigation is seen by the router only, and only if it is listening for popstate. A crash or a phone reclaiming the tab is seen by neither, which is the case only an autosaved draft covers. The reader does this beforeunload router guard draft closes the tab yes no yes reloads yes no yes navigates in-app no yes yes presses back no with popstate yes the tab is reclaimed no no yes Read the last column downwards: a draft is the only mechanism that covers every row, which is why it beats a warning.

Step-by-Step Walkthrough

  1. Derive isDirty from normalised values. A trailing space is not a change worth interrupting for.

  2. Register the listener only while dirty. An always-registered beforeunload can suppress the browser’s own back-forward cache, which slows every navigation away from the page.

  3. Guard the router separately. Same predicate, different mechanism, better dialog.

  4. Offer save, not just discard. “Save and continue” turns a warning into a service. It is the option readers actually want.

  5. Make the dialog accessible. It takes focus, has a heading, traps Tab, restores focus on cancel — everything in keyboard navigation patterns.

  6. Clear the flag on a confirmed save. Not when the request is sent — when it succeeds.

Failure Modes and Edge Cases

1. The browser does not show the dialog

beforeunload is ignored unless the reader has interacted with the page — a deliberate anti-abuse rule. There is no workaround, and this is the strongest argument for an autosaved draft: it protects the case the dialog cannot.

2. The warning fires when nothing changed

Almost always a comparison that has not been normalised, or a form that marks itself dirty when programmatic data arrives. Both are covered by tracking dirty against a baseline rather than against the initial render.

3. Submitting triggers the warning

A traditional form submission is a navigation. Clear the flag in the submit handler before the navigation begins, or the reader is asked whether they want to discard the thing they just submitted.

4. Custom wording in beforeunload

Every current browser ignores the returned string. Code that returns a carefully worded sentence is code that reads as intentional and does nothing.

5. The route guard blocks a redirect it should not

An expired session redirecting to sign-in should not be interrupted by an unsaved-changes prompt. Give programmatic navigations a way to bypass the guard.

What the router dialog can say that the browser cannot The browser dialog offers two options with wording you do not control, and cannot mention what is unsaved. Your own dialog can name the work — three unsaved answers — offer to save and continue rather than only to discard, explain what happens either way, and be made properly accessible with focus management and an announcement. That difference is the whole reason for handling the two departures separately rather than treating beforeunload as sufficient. the browser dialog two options: leave, or stay wording you cannot change cannot name what is unsaved cannot offer to save your router dialog three options: save, discard, stay names the unsaved work explains what each option does focus-managed and announced This is why the two are handled separately: one is a blunt safety net, the other is the actual conversation.

Verification Checklist

Common Pitfalls

  • Registering beforeunload unconditionally. It can disqualify the page from the back-forward cache, slowing every navigation away and back for every reader — including the ones with nothing unsaved. Add it when the form becomes dirty, remove it when it becomes clean.
  • Returning a custom string. Every current browser ignores it and shows its own wording. Code that returns a carefully worded sentence reads as intentional and does nothing.
  • Warning on a submit. A traditional submission is a navigation, so an unguarded handler asks the reader whether they want to discard the thing they just sent. Clear the flag before the navigation starts.
  • Comparing raw values. A trailing space the reader cannot see should not produce a warning. Compare normalised values, using the same normaliser the dirty tracking already has.
  • Blocking a forced redirect. An expired session sending the reader to sign in should not be interrupted by an unsaved-changes prompt they cannot act on. Give programmatic navigation a way past the guard.

Related

Dirty and Pristine State Tracking

Frequently Asked Questions

Can I customise the beforeunload message?

No. Every current browser shows its own wording and ignores any string you return, because customisable text was widely used to scare readers into staying. Treat beforeunload as a boolean — warn, or do not — and put all of your actual copy and options into the router guard, which you do control.

Why register the listener only while the form is dirty?

Partly hygiene, and partly performance: a registered beforeunload handler can disqualify the page from the browser’s back-forward cache, which makes navigating away and back noticeably slower for every reader, including the ones with nothing unsaved. Adding it when the form becomes dirty and removing it when it becomes clean costs two lines.

Is an unsaved-changes warning still needed if the form autosaves?

Usually not for the document-leaving case — that is exactly what the draft protects. It can still be worth a router guard where leaving has a consequence the draft does not cover, such as abandoning a step-locked application. Where you have both, make the warning say the work is saved rather than that it will be lost: readers who have been told their draft is safe should not then be told they are about to lose it.