CloseWatcher: Stop Faking History Entries to Handle the Android Back Button
If you've ever called history.pushState() just so the Android back button closes your modal instead of navigating away, you've written the hack the CloseWatcher API exists to delete. Every custom overlay on the web — modals, sidebars, pickers, bottom sheets — has to answer the same question: what happens when the user asks the platform to close it? On desktop that's Esc. On Android it's the back gesture. On iOS with VoiceOver it's the two-finger scrub. Until now, you handled the first with a keydown listener, the second with a fragile history-stack dance, and the third usually not at all.
CloseWatcher unifies all of these into a single concept the spec calls a close request, and hands it to you as one event. It's in Chrome/Edge 126+, Firefox 149+, and Samsung Internet 28+ — with Safari support in Technology Preview. Here's what it does, where the sharp edges are, and how to wrap it into a React hook you can drop into any overlay component.
What a "close request" actually is
The spec's framing is the key insight: a close request is a platform-mediated interaction intended to close an in-page component — distinct from your own "×" button, which is just a click you already control. The platform decides what a close request looks like: Esc on desktop, the back button/gesture on Android, an assistive-tech dismiss gesture elsewhere. Your code no longer cares which one it was.
Native <dialog> and popover="" already get this behavior for free — that's why a modal dialog closes on Esc without you writing anything. CloseWatcher exposes the same underlying machinery to fully custom components:
const watcher = new CloseWatcher();
watcher.onclose = () => {
sidebar.classList.remove("open");
};
// Route your own close button through the same path:
closeButton.addEventListener("click", () => watcher.close());That's the entire mental model. No keydown listener, no popstate juggling, no checking event.key === "Escape" while worrying about IME composition.
Watchers form a stack: only the most-recently-created active watcher receives the next close request. Nested overlays (a picker inside a modal) compose correctly by construction — the picker's watcher catches the first Esc/back-press, the modal's catches the second.
The API surface
The whole interface is small:
new CloseWatcher({ signal })— the optional AbortSignal destroys the watcher when you abort, which maps beautifully onto React effect cleanup (more below).requestClose()— fires cancel first (preventable), then close. Use this from your own UI so programmatic closes go through the same confirmation logic as platform closes.close()— skips cancel, fires close immediately. Unconditional.destroy()— deactivates the watcher without firing anything. Call this when the component unmounts for a reason unrelated to closing.cancelevent — your one chance to intercept ("you have unsaved changes").closeevent — actually tear the UI down.
The cancel/close split mirrors the dialog element's two-event pattern, and it's what makes unsaved-changes flows work:
watcher.oncancel = (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
showConfirmDialog();
}
};The user-activation rules you will trip over
This is the part most write-ups skip, and it's where real-world behavior gets surprising. The API is deliberately rate-limited so a hostile page can't trap users behind an infinite stack of back-button-eating watchers:
- One free watcher. You can create one CloseWatcher without transient user activation (e.g., for a session-timeout dialog that appears on its own). Every additional watcher created without fresh user activation gets grouped with the previous one — a single close request closes the whole group at once.
- cancel is gated on activation. The cancel event only fires as preventable if the page has received user activation since the last close request. A second consecutive back-press bypasses your cancel handler entirely. Users can always escape; your "are you sure?" gets exactly one shot.
- Native components share the budget. A modal dialog or popover opened without activation consumes the same free-watcher slot.
The guarantee the spec settles on: a maximally abusive page costs the user at most N + 2 back presses to escape, where N is the number of activations they granted. In practice this means: create watchers in direct response to user interaction (opening a modal from a click is fine), and never rely on cancel firing twice in a row.
Wiring it up in React
The AbortSignal option makes the React integration almost suspiciously clean — effect cleanup is just controller.abort():
import { useEffect, useRef } from "react";
type Options = {
onClose: () => void;
onCancel?: (e: Event) => void; // call e.preventDefault() to block
};
export function useCloseWatcher(active: boolean, { onClose, onCancel }: Options) {
const watcherRef = useRef<CloseWatcher | null>(null);
const cbRef = useRef({ onClose, onCancel });
cbRef.current = { onClose, onCancel };
useEffect(() => {
if (!active || typeof CloseWatcher === "undefined") return;
const controller = new AbortController();
const watcher = new CloseWatcher({ signal: controller.signal });
watcherRef.current = watcher;
watcher.addEventListener("close", () => cbRef.current.onClose());
watcher.addEventListener("cancel", (e) => cbRef.current.onCancel?.(e));
return () => {
watcherRef.current = null;
controller.abort(); // destroys the watcher
};
}, [active]);
// expose requestClose so in-app close buttons share the cancel/close path
return () => watcherRef.current?.requestClose();
}Two deliberate choices here. Callbacks live in a ref so the effect only re-runs when active flips — recreating a watcher on every render would churn through the activation budget and break the stack ordering. And the hook returns requestClose, because your own close button should go through the same cancel gate as the platform's close request.
Usage in a modal:
function EditorModal({ open, onDismiss }: { open: boolean; onDismiss: () => void }) {
const [dirty, setDirty] = useState(false);
const requestClose = useCloseWatcher(open, {
onClose: onDismiss,
onCancel: (e) => {
if (dirty && !confirm("Discard unsaved changes?")) e.preventDefault();
},
});
if (!open) return null;
return (
<div role="dialog" aria-modal="true">
<textarea onChange={() => setDirty(true)} />
<button onClick={requestClose}>Close</button>
</div>
);
}Now Esc, the Android back gesture, and the Close button all funnel through one code path, and the unsaved-changes guard applies to all three. This is exactly the pattern component libraries are converging on — Radix, MUI, Ariakit, and react-aria all have open issues or shipped PRs to replace their Esc handlers with CloseWatcher; Shoelace already landed it.
Feature detection and the Safari question
Support as of mid-2026: Chrome/Edge 126+, Firefox 149+ (desktop and Android), Samsung Internet 28+, Opera 112+. Safari has it in Technology Preview but not stable, so iOS is the gap. Chrome technically shipped it in 120, but it was pulled after a dialog cancel-event regression and re-landed in 126 — treat 126 as the floor.
The fallback story is graceful because the hook already guards with typeof CloseWatcher === "undefined". For non-supporting browsers, keep a plain keydown fallback:
useEffect(() => {
if (!active || typeof CloseWatcher !== "undefined") return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.isComposing) onCloseRef.current();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [active]);Safari users lose nothing they ever had — there's no back button on iOS Safari to integrate with anyway — and Android users on supporting browsers get a back gesture that finally does what they expect. There's also a community polyfill (linked from the WICG repo) if you want uniform behavior, but the progressive-enhancement path is simpler and has no history-API side effects.
One more escape hatch worth knowing: if your overlay can be expressed as a native <dialog> or popover, do that instead. You get close-request handling, focus management, and top-layer rendering for free, and the browser wires the same close-watcher infrastructure underneath. CloseWatcher is for the cases where you genuinely can't — custom-rendered sheets, canvas UIs, in-page "views" that aren't dialogs.
The bigger shift: UI state is not navigation state
The reason CloseWatcher matters goes beyond deleting a keydown listener. For a decade, the only way to make the Android back button close an overlay was to lie to the History API — push a fake entry, listen for popstate, hope the user doesn't refresh mid-modal and end up with a broken back stack. Every SPA router grew warts to accommodate this.
CloseWatcher is the platform admitting that close-the-thing-on-screen and go-back-a-page are different intents that happen to share a button on one OS. Treat them separately: history entries for actual navigations, close watchers for transient UI. If you're building or maintaining a component library, this is worth adopting now behind feature detection — the API surface is stable, the spec is in WHATWG HTML proper (not a WICG draft anymore), and every month more of your Android users land on a browser where Esc-only close handling feels broken. The history-hack era earned its retirement.