pushState vs replaceState: When to Use Each

After reading this you will be able to decide, for any URL change in a single-page app, whether it deserves its own back-button entry via pushState or should quietly rewrite the current entry via replaceState — and wire that decision into one small routing helper.

← Back to pushState & replaceState Usage

Prerequisites

Core Concept

Both methods change the URL and the associated history.state without a network round-trip, and both belong to the History API & State Management surface — the only difference is what they do to the back button. history.pushState appends a new entry to the session history stack, so the previous URL becomes reachable with a single back press. history.replaceState overwrites the current entry in place, leaving the stack length unchanged, so the back button skips straight past it to whatever came before. That single distinction drives every decision: ask “should pressing back return the user to the URL before this change?” If yes, push. If no — because the change is a canonicalisation, a redirect, or a correction the user should not have to click through — replace.

The classic mistakes are symmetrical. Pushing on every keystroke of a search box or every filter toggle floods the stack, so the user must press back a dozen times to escape a page they visited once — a poor experience that also confuses analytics. Replacing when you should push destroys legitimate history, so back navigation silently jumps too far and the user loses their place. The rule of thumb: meaningful destinations push; in-place refinements of the current destination replace.

Choosing between them

Situation Method Why
Navigating to a genuinely new view or route pushState The old view should be one back-press away.
Applying a filter, sort, or tab within the same view replaceState It refines the current URL; back should exit the view, not undo each toggle.
Live-updating a search query as the user types replaceState Every keystroke would otherwise become a history entry.
Canonicalising a URL (adding a default param, fixing casing) replaceState The pre-canonical URL should never be a back target.
A client-side redirect (/old/new) replaceState Back must skip the redirect and not bounce the user forward again.
De-duplicating an identical consecutive navigation replaceState Avoids two adjacent entries with the same URL.
Opening a modal or detail pane you want dismissible with back pushState Back should close the overlay as a distinct step.

Implementation

Encode the decision once. The helper below picks the method from the intent of the navigation rather than sprinkling raw pushState/replaceState calls throughout the app, and it collapses a same-URL push into a replace to prevent accidental duplicate entries.

// TypeScript 5.x — framework-agnostic, no runtime dependencies

type NavIntent =
  | "navigate"     // a new destination — earns a back-button entry
  | "refine"       // filter/sort/tab within the current view — rewrite in place
  | "redirect"     // client-side redirect — must not be a back target
  | "canonicalise"; // normalise the URL — never a back target

const REPLACE_INTENTS: ReadonlySet<NavIntent> = new Set([
  "refine",
  "redirect",
  "canonicalise",
]);

export function routeTo(url: string, intent: NavIntent, state: unknown = null): void {
  const isSameUrl = url === location.pathname + location.search + location.hash;

  // A "navigate" to the URL we are already on would create a duplicate
  // adjacent entry; downgrade it to a replace so back stays meaningful.
  const shouldReplace = REPLACE_INTENTS.has(intent) || (intent === "navigate" && isSameUrl);

  if (shouldReplace) {
    history.replaceState(state, "", url);
  } else {
    history.pushState(state, "", url);
  }
}

// Usage:
// routeTo("/products/42", "navigate");            // new detail view → pushes
// routeTo("/list?sort=price", "refine");          // sort change    → replaces
// routeTo("/dashboard", "redirect");              // /home → /dashboard → replaces

Because neither method dispatches a popstate event, remember to render the new view yourself after calling routeTo; popstate only fires when the user navigates the stack with the back/forward buttons.

Verification

Assert on the history length before and after: a push increments history.length, a replace leaves it unchanged. Playwright drives the real back button so you can confirm the stack behaves as intended.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("refine replaces, navigate pushes", async ({ page }) => {
  await page.goto("/list/");
  const start = await page.evaluate(() => history.length);

  // A refinement must NOT grow the stack.
  await page.evaluate(() => (window as any).routeTo("/list/?sort=price", "refine"));
  expect(await page.evaluate(() => history.length)).toBe(start);

  // A real navigation must add exactly one entry.
  await page.evaluate(() => (window as any).routeTo("/list/42", "navigate"));
  expect(await page.evaluate(() => history.length)).toBe(start + 1);

  // Back should land on the refined list, not the pre-sort URL.
  await page.goBack();
  await expect(page).toHaveURL(/sort=price/);
});

For a quick manual check, read history.length in DevTools, apply a filter, and confirm the number did not move; then click into a detail view and confirm it went up by one and that a single back press returns you to the list.

Gotchas

  • Neither pushState nor replaceState fires popstate, so if the UI does not update after a call, the bug is a missing render, not a history problem.
  • Rapid refine calls (typeahead) are correct to replace, but still throttle the DOM re-render — replacing is cheap, re-rendering the whole view on every keystroke is not.
  • A redirect implemented with pushState traps the user: pressing back re-enters the old URL, which redirects forward again, creating a loop — always use replaceState for client-side redirects.
  • De-duplicating consecutive identical URLs with replaceState is the clean fix; for the dedicated pattern and edge cases see Preventing Duplicate History Entries with replaceState.

FAQ

When should I use replaceState instead of pushState? Use replaceState when the URL change refines the current view rather than moving to a new one — filters, sort order, tab selection, client-side redirects, and URL canonicalisation. It rewrites the current entry in place so the back button skips past it instead of stepping through every intermediate state.

Does replaceState create a back-button entry? No. replaceState overwrites the current history entry and leaves history.length unchanged, so the back button jumps to whatever preceded the current entry. Only pushState appends a new entry that the back button can return to.

Which method should I use for filters and sorting? Use replaceState for filters, sorting, and pagination within the same view. Each toggle is a refinement of the current URL, not a new destination, so pressing back should exit the view rather than undoing each filter one press at a time.

Do pushState and replaceState fire a popstate event? No. Neither method fires popstate — that event only fires when the user moves through the history stack with the back or forward buttons. After calling either method you must update the view yourself in the same code path.

How do I stop the back button from needing many presses to leave a page? Stop pushing an entry for every in-place change. Reserve pushState for genuinely new destinations and use replaceState for refinements like search input, filters, and canonicalisation, so a single view occupies a single history entry.