Preventing Duplicate History Entries with replaceState

After reading this you will be able to swap a redundant pushState call for a conditional replaceState so that a single back-button press always exits the current view, never two or three.

← Back to pushState & replaceState Usage

Prerequisites

Core Concept

A duplicate history entry is an extra item on the browser’s session stack that points at a URL identical to the one already on top. It appears whenever routing code calls history.pushState() for navigation that does not actually change the location — a re-clicked tab, a re-applied filter, or a click handler that fires twice before the first transition settles. Because pushState appends unconditionally and never compares the target against the current URL, each redundant call grows the stack by one, and the user has to press back once per phantom entry to escape a view that visually never changed. This is the “back-button loop”, and it quietly inflates history.length while degrading the navigation guarantees the rest of your History API & State Management layer depends on.

The fix is to make the decision between appending and mutating explicit. When the target URL equals the current one, call history.replaceState() to overwrite the top entry in place instead of stacking a new one; only when the URL genuinely differs do you pushState. This keeps the stack depth proportional to real navigation, which in turn makes popstate event handling and scroll restoration behave predictably.

It helps to picture the two methods as the same operation with a single flag flipped. Both accept the identical (state, unused, url) signature and both update the address bar without a network round-trip; the only difference is whether the browser keeps a snapshot of the old entry. pushState keeps it (so back returns to it), replaceState discards it (so back skips straight past). Framing the choice this way makes the rule memorable: every navigation that a user would not expect to undo with the back button should be a replace, and everything else a push. Filters, sorts, tab toggles, “mark as read” URL flags, and optimistic query-parameter writes all fall on the replace side; following a link, submitting a form that lands on a new resource, and opening a detail view fall on the push side.

Implementation

The core is a single guard that normalises both URLs, compares them, and routes the call to the correct History API method. Normalisation matters because /list and /list? or /list# are the same view to a user but distinct strings to ===.

// TypeScript 5.x — framework-agnostic, uses the native History API only
interface RouteState {
  page: string;
  scrollY?: number;
}

// Collapse cosmetic differences so equal views compare as equal.
function normalise(url: string): string {
  const u = new URL(url, window.location.origin);
  // Drop a trailing slash (but keep the root "/") and the empty "?" / "#".
  const path = u.pathname.replace(/\/+$/, "") || "/";
  return path + (u.search === "?" ? "" : u.search);
}

function safeNavigate(url: string, state: RouteState): void {
  const current = normalise(window.location.pathname + window.location.search);
  const target = normalise(url);

  if (target === current) {
    // Same view: mutate the top entry — no new back-button stop is created.
    window.history.replaceState({ ...window.history.state, ...state }, "", url);
  } else {
    // Genuinely different view: append a real navigational entry.
    window.history.pushState(state, "", url);
  }
}

Two details carry the correctness. First, { ...window.history.state, ...state } merges rather than overwrites, so a replaceState does not silently erase scroll offsets or session data already stored on the current entry. Second, rapid triggers (search-as-you-type, infinite scroll) can still fire several calls before the comparison settles, so wrap high-frequency callers in a debounce. The same merge discipline keeps a stored scroll offset intact, which is the foundation that manual scroll restoration for SPAs builds on:

// TypeScript 5.x — debounce wrapper for high-frequency navigation triggers
function debounce<A extends unknown[]>(fn: (...args: A) => void, ms = 120) {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: A): void => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

// A search input updating ?q= should replace, not stack, every keystroke.
const updateQuery = debounce((q: string) => {
  safeNavigate(`/search?q=${encodeURIComponent(q)}`, { page: "search" });
});

Verification

Confirm the fix with a deterministic stack check in the DevTools console. Record the depth, drive the interaction that used to duplicate, and assert the depth held steady.

// TypeScript 5.x — run in the DevTools console
const before = window.history.length;
// Re-apply the same filter / re-click the active tab three times:
safeNavigate("/list?filter=open", { page: "list" });
safeNavigate("/list?filter=open", { page: "list" });
safeNavigate("/list?filter=open", { page: "list" });
console.assert(
  window.history.length === before,
  `Expected stable depth, grew by ${window.history.length - before}`,
);

If the assertion holds, a single back-click now exits the view. You can also open the DevTools Application panel, inspect the Frame/History section, and watch that no new rows appear during repeated same-URL interactions.

For a longer-lived signal, log history.length at the start and end of a representative user session and compare the growth against the number of genuine route changes you expect. The two numbers should track closely; a session that visits eight distinct pages should end roughly eight entries deeper, not forty. A growing gap between expected and observed depth is the clearest telemetry that a redundant pushState has crept back in — often from a newly added analytics hook or a third-party widget that drives its own navigation. Capturing this metric in an end-to-end test that replays a scripted sequence of clicks turns the regression into a build failure rather than a support ticket.

Gotchas

  • replaceState on a genuinely different route traps users in one view by discarding the entry they would have gone back to — only ever replace when the normalised URLs match.
  • Forgetting to merge state means each replaceState wipes whatever was stored on the entry; spread the existing window.history.state in before applying new fields.
  • popstate re-entrancy: if a popstate listener itself calls navigation logic, check the event source first, or back/forward navigation will recreate the very duplicates you removed — see popstate event handling for safe listener patterns.
  • Comparing raw strings treats /x, /x/, and /x? as three routes; normalise trailing slashes, empty query strings, and hashes before the equality check.

FAQ

When should I use replaceState instead of pushState? Use replaceState when the navigation does not change the destination view — updating a query parameter, toggling a filter, or correcting the current URL — so no new back-button stop is created. Use pushState only when moving to a genuinely different route the user should be able to return from.

Does replaceState affect SEO or crawler indexing? No. replaceState only mutates the current entry and does not request a new document, so crawlers still see the final rendered URL; it actually helps by preventing artificial URL inflation that would otherwise dilute crawl budget.

How do I preserve scroll position when using replaceState? Merge the existing entry’s state into the new one — replaceState({ ...window.history.state, scrollY: window.scrollY }, "", url) — or store window.scrollY separately before the call and restore it when handling the corresponding navigation event.

Why does my history still grow after adding the check? Almost always because the two URLs differ only cosmetically (a trailing slash, an empty ?, or reordered query params) and slip past a raw ===. Run both sides through a normaliser, as the implementation above does, before comparing.