On the browser's window object, what exactly makes the popstate event fire versus the hashchange event, and does either fire when a script calls history.pushState()?
answer
- who moved the entry pointer?
- traversal versus fragment navigation
- script writes are always silent
- one Back can deliver both events
- first entry has null state
basics
~20 spopstate fires when the user traverses session history — Back, Forward, or history.go() — within the same document. hashchange fires when the URL fragment changes through a navigation, such as clicking an anchor link or assigning location.hash. Neither fires for pushState or replaceState.
solid answer
~50 sThink in terms of *who moved the entry pointer*. `popstate` is the traversal event: it fires on `window` when the browser moves to a different session history entry inside the same document — the Back and Forward buttons, a back gesture, or `history.back()` / `forward()` / `go(n)`. Its `event.state` is the target entry's stored state, which is `null` for an entry that never had one. `hashchange` is the fragment event: it fires when a same-document navigation changes the part after `#`, which happens when the user clicks `<a href="#section">` or a script assigns `location.hash`. Those fragment navigations create a history entry but fire only `hashchange`. Traversing *back* across such an entry fires `popstate` first and then `hashchange`, because both facts are true. `pushState` and `replaceState` fire neither, even when the pushed URL differs only in its fragment — the caller already knows.
go deeper
Recall that popstate means the user pressed Back or Forward and hashchange means the part after # changed, and that both listeners go on window.
Explain the split by who moved the history pointer, know that script-driven pushState and replaceState fire nothing, and that a single Back across a fragment change delivers popstate and then hashchange.
Design handlers that are idempotent and derive everything from location plus history.state, and recognise the bug pattern where a hash-driven widget and a path-based router fight over the same session history.
Decide the app-wide convention — path routing, fragment routing, or a deliberate mix — and hold the line, since the failure mode is two subsystems writing the same session history with incompatible assumptions.
## The mental model: who moved the pointer Session history is a list of entries with a pointer at the current one. Three kinds of things happen to that structure, and each has different event consequences. 1. **A script rewrites entries.** `history.pushState()` inserts an entry; `history.replaceState()` overwrites one. Silent — no `popstate`, no `hashchange`, no matter what changed in the URL. 2. **A same-document navigation happens.** Clicking `<a href="#pricing">` or assigning `location.hash = 'pricing'` performs a navigation that stays in the document: the browser adds a history entry, scrolls to the target element, and fires `hashchange` on `window`. No `popstate` — the pointer moved forward to a brand-new entry rather than traversing to an existing one. 3. **The pointer traverses.** Back, Forward, a trackpad or Android back gesture, `history.back()`, `history.forward()`, `history.go(n)`. If the target entry belongs to the same document, `popstate` fires with the entry's state; if the fragment also differs between the two entries, `hashchange` fires afterwards. If the target belongs to a *different* document, the current page is torn down instead and there is no `popstate` for it. ## popstate in detail `popstate` is dispatched on `window`, not on `document` — attaching to the wrong target is a common silent failure. `event.state` is a fresh structured clone of the state stored on the entry being traversed to. It equals what `history.state` reads immediately afterwards. It is `null` for any entry that was created without state, which importantly includes the entry from the original page load. That is why routers call `replaceState` at startup: so that traversing back to the first entry delivers a state object of the same shape as every other entry rather than `null`. `popstate` does **not** fire on initial page load in current Chrome, Firefox and Safari. Very old WebKit did fire it, which is why some legacy code has a guard for a spurious initial event; that guard is no longer needed. The event is not cancelable and carries no destination information beyond the state — you read `location` inside the handler to learn where you now are. It also gives you no way to *prevent* the traversal; blocking Back is not something this API offers. ## hashchange in detail `hashchange` fires at `window` with `oldURL` and `newURL` properties holding the full URLs before and after. It fires only when the fragment actually changes: assigning `location.hash` to its current value does nothing. The fragment is a client-side concept — it is never sent in the HTTP request — which is why hash routing needs no server configuration. It is also why a fragment navigation does not unload the document. ## The combination table | Action | popstate | hashchange | |---|---|---| | `history.pushState(s, '', '/a')` | no | no | | `history.pushState(s, '', '#b')` | no | no | | `history.replaceState(s, '', '/a')` | no | no | | Click `<a href="#b">` on the same page | no | yes | | `location.hash = 'b'` | no | yes | | Back to an entry with the same path | yes | no | | Back to an entry with a different fragment | yes | then yes | | Back to an entry in a different document | no (page is replaced) | no | The row people get wrong in interviews is the second: pushing a URL that differs only in its fragment fires nothing at all. Code that changes the fragment through `pushState` while also relying on a `hashchange` listener will simply never react. ## Practical consequences **Two routers must not coexist accidentally.** A path-based router listening to `popstate` and a widget that drives its own state through `location.hash` will interfere: the widget's assignments create history entries the router did not push, and traversal over them delivers `popstate` with a state the router does not recognize. Decide on one mechanism. **Handlers must be idempotent and location-driven.** Because a single Back press can deliver `popstate` followed by `hashchange`, a handler that re-renders on both will run twice. Render from `location` and `history.state` so a second run is a no-op rather than a duplicated side effect. **You cannot veto a traversal.** `popstate` arrives after the fact and is not cancelable. Guarding unsaved work against Back is not solvable with this event. ```js window.addEventListener('popstate', (e) => { // e.state is the target entry's state, or null if it had none render(location.pathname, e.state); }); window.addEventListener('hashchange', (e) => { console.log('fragment moved', e.oldURL, '->', e.newURL); }); ```
- A page at /a#one calls history.pushState({}, '', '/a#two'). Which events fire?None. pushState never fires popstate, and it never fires hashchange either, even though only the fragment changed. Code that mixes pushState with a hashchange listener silently stops reacting. If you want the fragment change observed, either assign location.hash instead or call your render function directly after pushing.
- Why is event.state null when the user goes back to the page they originally landed on?The entry created by the initial page load has no associated state, and popstate hands you the target entry's state verbatim. Routers avoid the special case by calling replaceState during startup so that first entry carries the same shape of state as every entry they push later.
- Can you use popstate to stop a user from navigating back away from unsaved work?No. popstate is dispatched after the traversal has already happened and is not cancelable, and it carries no information about where the user came from. It is a notification, not a gate.
saying these in an interview costs you the question
- Expects hashchange when pushState changes only the fragment
- Registers the popstate listener on document rather than window
- Believes popstate fires on the initial page load
- Thinks popstate can be cancelled to block the Back button
- Assumes event.state is always an object