Skip to content

replayIntegration storage instrumentation silently misroutes localStorage.setItem writes to sessionStorage when called through a pre-init reference #20876

@steeevio

Description

@steeevio

Is there an existing issue for this?

How do you use Sentry?

Sentry Saas (sentry.io)

Which SDK are you using?

@sentry/vue

SDK Version

10.46.0

Framework Version

3.5.31

Link to Sentry event

No response

Reproduction Example/SDK Setup

import * as Sentry from "@sentry/vue";

// Module-level — runs before Sentry.init()
const earlyRef = window.localStorage;

Sentry.init({
  app,
  dsn: "...",
  integrations: [Sentry.replayIntegration()],
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
});

Steps to Reproduce

Capture a reference to window.localStorage at module load — before Sentry.init() runs. This happens naturally when a persistence library accepts a storage option configured at module scope (we hit it via pinia-plugin-persistedstate, which captures the supplied storage once at store definition).

Call Sentry.init() with replayIntegration() enabled.

Later (e.g. on user interaction), call .setItem(key, value) through the captured reference:

earlyRef.setItem("demo", "hello");

Inspect both storage backends:

localStorage.getItem("demo"); // null
sessionStorage.getItem("demo"); // "hello"

Expected Result

The write reaches localStorage. Calling .setItem on a localStorage reference should mutate localStorage regardless of when that reference was acquired relative to Sentry.init().

Actual Result

The write is silently redirected into sessionStorage. No error is thrown, no console warning, no breadcrumb. The key never appears in localStorage, and data intended to persist disappears on tab close.

Inspecting Storage.prototype.setItem after init shows the patched wrapper (minified):

function () {
  let t = null, a = false;
  return this === i.localStorage.proxy
    ? (a = true,  t = i.localStorage.original)
    : (a = false, t = i.sessionStorage.original),
    e(arguments, a),
    o.apply(t, arguments);
}

The else branch writes to sessionStorage.original for any receiver that is not strictly equal to i.localStorage.proxy — including a raw localStorage reference captured before init. A receiver check via instanceof Storage plus comparison against both localStorage.original and sessionStorage.original, or a WeakMap lookup, would route correctly.

Additional Context

Confirmed in production with replaysSessionSampleRate: 0.1 and replaysOnErrorSampleRate: 1.0 (instrumentation runs regardless of sampling).

The bug is invisible without explicit verification — applications appear to "lose" persisted state on reload while the writes are actually landing in sessionStorage.

Symmetric risk exists for code holding a sessionStorage reference taken before init, although we have not verified that direction.

Workaround (in user code) is to late-bind through window.localStorage so each call resolves the current (proxied) instance:

const lateBoundLocalStorage = {
  getItem:    (k: string) => window.localStorage.getItem(k),
  setItem:    (k: string, v: string) => window.localStorage.setItem(k, v),
  removeItem: (k: string) => window.localStorage.removeItem(k),
};

Likely affects any persistence library that takes a Storage option at module scope (e.g. pinia-plugin-persistedstate, redux-persist, zustand/middleware/persist with custom storage).

Priority

React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it.

Metadata

Metadata

Assignees

No one assigned
    No fields configured for issues without a type.

    Projects

    Status

    Waiting for: Community

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions