In a client-side router, when should you call history.replaceState() instead of history.pushState(), and what goes wrong if you always push?
answer
- one adds an entry, one overwrites
- would Back undo this, or skip it?
- filters and redirects should not stack
- history.length never shrinks
- no API deletes history entries
basics
~20 spushState adds a session-history entry, replaceState overwrites the current one. Use replaceState for high-frequency URL updates such as filters and for redirects; pushing on every keystroke buries the previous page under dozens of entries and breaks the Back button.
solid answer
~50 sThey differ in one thing: `pushState` appends a new entry to the session history and makes it current, so Back returns to where the user was; `replaceState` rewrites the current entry's URL and state in place, leaving `history.length` unchanged and Back pointing at whatever preceded it. Push for navigations the user would expect to undo — opening a detail page, moving between tabs of an app. Replace for URL updates that are not really navigations: a search box or slider syncing `?q=` on every input, canonicalizing a sloppy URL, seeding state on first load, and redirects such as /login → /dashboard, which the user should not be able to re-enter with Back. The failure mode of over-pushing is a history trap: forty filter tweaks mean forty presses of Back to leave the app, and on mobile the OS back gesture feels broken. There is no API to trim history, so the only cure is not creating the entries.
code
javascript · 19 lines// Enter the filtered view once: this IS a navigation.
function openFilters() {
history.pushState({ view: 'filters' }, '', '/products?view=filters');
render();
}
// Subsequent tweaks only rewrite the current entry.
function setFilter(name, value) {
const url = new URL(location.href);
url.searchParams.set(name, value);
history.replaceState(history.state, '', url);
render();
}
// A redirect must not stay on the stack.
function afterLogin() {
history.replaceState({ view: 'dashboard' }, '', '/dashboard');
render();
}go deeper
Know the one-line difference: push adds a history entry, replace overwrites the current one, and the Back button is what makes the distinction visible to the user.
Name the concrete cases for replace — filter and search syncing, redirects, canonicalization, seeding state at boot — and explain that history.length counts the whole tab and never shrinks.
Diagnose a history trap from user reports about a broken Back button, and know that no API can repair the stack afterwards, so the fix is a policy on which state changes are navigations.
Frame session history as user-owned shared state and set the app-wide rule for what counts as a navigation, including how analytics, deep links, and mobile back gestures should read that same taxonomy.
## The one difference Both calls take the same arguments — `(state, unused, url)` — and both are silent, firing no `popstate` or `hashchange`. The distinction is what happens to the session history entry list: - `pushState` **inserts** a new entry after the current one, discards any forward entries, and makes the new entry current. `history.length` grows by one. - `replaceState` **overwrites** the current entry's URL and state. The list length and position are unchanged, and forward entries are preserved. Everything else follows from that. Push makes the previous view reachable by Back; replace makes it as if the current view had always had this URL. ## When replace is the right call **High-frequency URL syncing.** A search field, a price slider, a sortable table — anything that writes its state into the query string as the user manipulates it. Each keystroke is not a navigation. Push once when the user *enters* the filtered view, then replace as the parameters change. ```js let timer; input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(() => { const url = new URL(location.href); url.searchParams.set('q', input.value); history.replaceState(history.state, '', url); // no new entry }, 200); }); ``` Note the debounce: even `replaceState` should not run on every keypress, because some browsers throttle rapid history mutations, and rewriting the URL sixty times a second is wasted work. **Redirects.** If /old-path forwards to /new-path, or /login forwards to /dashboard after a successful sign-in, push would leave the intermediate URL on the stack, and Back would bounce the user straight back into the redirect — a loop they cannot escape. Replace removes the intermediate step from history entirely. **Canonicalization.** The user pasted `/Products?ref=email&utm_source=x`. Rewriting it to `/products` with `replaceState` cleans the address bar and future bookmarks without adding a step. **Seeding state on load.** The initial entry created by a normal page load has `history.state === null`. A router that wants every entry to carry state calls `replaceState({ key: crypto.randomUUID() }, '', location.href)` at boot so the first entry is not the odd one out. ## The cost of over-pushing History is a shared resource owned by the user, not by your app. Over-pushing produces a **history trap**: the user opened your app from a search result, changed a few filters, and now needs eight presses of Back to get out. On Android the hardware back gesture is the same stack, so the app appears to refuse to close. Analytics also degrades, because every pseudo-navigation looks like a page view. There is no way to repair it after the fact. The History API exposes no method to delete entries, enumerate them, or truncate the stack; `history.length` is read-only, and `history.go(-n)` merely traverses. Chrome ships a history-manipulation intervention that skips over entries created by a page the user never interacted with, which softens the worst abuse, but you cannot rely on it. The only real control is deciding push versus replace at the moment of the change. ## What history.length actually counts `history.length` is the number of entries in the **whole tab's** session history, not your app's. A tab that visited two other sites before yours starts at 3. It never decreases from replaces, and it is capped by the browser. Because you cannot see the entries or their URLs — that would leak browsing history across origins — code such as `if (history.length > 1) history.back()` is unreliable: the previous entry may belong to another site, and you have just sent the user off your app. A router that needs a trustworthy "is there somewhere to go back to inside this app" answer must track its own depth in the state it pushes. ## A useful rule of thumb Ask: *if the user pressed Back right now, what would they expect to see?* If the answer is "the thing before this change", push. If the answer is "the thing before I arrived at this screen at all", replace. Getting that question right for each state change is the whole of the design.
- A filter panel pushed forty entries before anyone noticed. Can you clean the history up afterwards?No. The History API exposes no way to delete, enumerate, or truncate entries; history.length is read-only and go() only traverses. You can replaceState the current entry and stop creating new ones going forward, but the forty are the user's to walk back through. Prevention is the only fix.
- Is `if (history.length > 1) history.back()` a safe implementation of an in-app Back button?No. history.length counts the entire tab's session history, including pages from other sites visited before yours, and you cannot inspect those entries. The check can send the user off your app entirely. Track your own depth in the pushed state, or render a link to the parent route instead.
- Why call replaceState during application startup?The entry created by the initial page load carries no state, so history.state is null and a popstate handler that assumes a state shape breaks when the user traverses back to it. Calling replaceState at boot with the router's own state — a key or the resolved route — makes the first entry look like every other one.
saying these in an interview costs you the question
- Believes replaceState removes an entry from history
- Pushes a new entry on every keystroke or slider move
- Uses history.length to decide whether to go back
- Thinks history.length decreases when you replace
- Claims you can delete or read past history entries