/
Go back

Versioning and Migrating localStorage Without Breaking Your App

Safely version, validate, and migrate browser-stored preferences across application updates.

Random artwork.

Yes, I am using JS here and not TS. I don’t like it either, but we gotta embrace the bad to see the good.

If you have ever made a production web app, chances are that you have stored some non-sensitive state in a user’s browser. This saves you from putting every tiny preference in your application’s database. Examples include filter settings, table-column ordering, theme preferences, or the most recently viewed page so a user can resume where they left off after authentication.

I am going to use localStorage in this post. People often call this data a “browser cache,” but localStorage is technically persistent browser storage. It is separate from the HTTP cache and the Cache API.

The data normally survives browser restarts, but it is not a durable source of truth. The user can clear it, private-browsing sessions can discard it, and it does not automatically follow the user to another device. If a preference must be durable or synchronized, store it on the server.

A Security Note Before We Begin

I would like to reiterate that this is only for non-sensitive data. Any JavaScript running on your origin can read localStorage, including malicious JavaScript introduced through an XSS vulnerability. A user can also edit these values manually, so treat everything you read from browser storage as untrusted input.

Do not store API keys, passwords, session IDs, refresh tokens, or data used to make authorization decisions here. Keep sensitive data and the checks protecting your core business logic on the server.

Why Application Updates Break Persisted State

The JSON stored in a browser can outlive many deployments. Eventually, a component’s state shape or the meaning of one of its fields will change. This problem is not specific to React: any frontend can load old data that no longer matches what the current application expects.

At that point, we have two choices:

  1. Invalidate the old state. Ignore it and use the new defaults. This is ideal for cheap, disposable preferences.
  2. Migrate the old state. Convert it to the new shape so the user keeps their preferences. This is worth doing when resetting the state would be annoying or disruptive.

Whichever route we choose, we need to version the stored data’s schema, not the application release. A deployment that does not change the data shape does not need a new storage version.

The Simple Option: Invalidate With a Versioned Key

A versioned key makes the newest implementation fall back to its defaults when no compatible entry exists:

const STORAGE_KEY_PREFIX = "my-app.preferences";
const COMPONENT_IDENTIFIER = "orders-table";
const STORAGE_SCHEMA_VERSION = 2;

const STORAGE_KEY = `${STORAGE_KEY_PREFIX}.${COMPONENT_IDENTIFIER}.v${STORAGE_SCHEMA_VERSION}`;

This is intentionally boring. Boring is good. When version 2 ships, it will not accidentally read version 1’s state. The trade-off is that the old preferences are lost and the old key remains until we explicitly remove it.

Use a namespace such as my-app.preferences because localStorage is shared by every page on the same origin. Namespacing reduces the chance of your keys colliding with another part of the application.

Reading Without Trusting the Data

localStorage stores strings, so objects need to be parsed after we read them. Both accessing localStorage and parsing JSON can fail, which is why I like to wrap the whole read in a try...catch:

function readStoredState(key, fallback, isValid) {
  try {
    const cachedData = window.localStorage.getItem(key);

    if (cachedData === null) {
      return fallback;
    }

    const parsedData = JSON.parse(cachedData);

    if (!isValid(parsedData)) {
      console.warn(`Ignoring invalid cached data for "${key}"`);
      return fallback;
    }

    return parsedData;
  } catch (error) {
    console.warn(`Could not read cached data for "${key}"`, error);
    return fallback;
  }
}

function isTablePreferences(value) {
  return (
    typeof value === "object" &&
    value !== null &&
    Array.isArray(value.hiddenColumns) &&
    value.hiddenColumns.every((column) => typeof column === "string")
  );
}

const preferences = readStoredState(
  STORAGE_KEY,
  { hiddenColumns: [] },
  isTablePreferences,
);

The validation step matters. Successfully parsing JSON only tells us that the value is valid JSON; it does not tell us that it has the shape our application expects. In a real project, you could replace the small predicate above with a schema-validation library.

Also, window and localStorage do not exist during server-side rendering. Run this code in the browser, for example, in a client-side initializer or an effect, not while rendering on the server.

Saving Data Safely

Similarly, we need to serialize complex values before saving them. If we pass an object directly to setItem, it is coerced to a string such as "[object Object]", which is not what we want happening.

There are a few different failure modes to consider:

OperationFailureCause
Accessing or writing localStorageSecurityErrorThe origin is invalid, or browser/user policy blocks persistence
localStorage.setItemQuotaExceededErrorThe value cannot be stored, commonly because the origin’s quota is full
JSON.stringifyThrows a TypeErrorThe value contains a circular reference or an unsupported BigInt
JSON.stringifyReturns undefinedThe top-level value is undefined, a function, or a symbol

Functions, symbols, and undefined nested inside an object are omitted rather than causing an error; inside an array they become null. That silent data loss is another good reason to control and validate the shape we store. JSON is not lossless for every JavaScript type either: for example, dates come back as strings, while Map and Set need an explicit conversion.

Here is a small helper that handles the failures without crashing the app:

function writeStoredState(key, data) {
  let serialized;

  try {
    serialized = JSON.stringify(data);
  } catch (error) {
    console.warn(`Cannot serialize value for "${key}"`, error);
    return false;
  }

  if (serialized === undefined) {
    console.warn(`Value for "${key}" is not serializable`);
    return false;
  }

  try {
    window.localStorage.setItem(key, serialized);
    return true;
  } catch (error) {
    if (error instanceof DOMException && error.name === "QuotaExceededError") {
      console.warn(`Storage is full; could not save "${key}"`);
      return false;
    }

    if (error instanceof DOMException && error.name === "SecurityError") {
      console.warn(`Browser storage is unavailable for "${key}"`);
      return false;
    }

    console.warn(`Could not save "${key}"`, error);
    return false;
  }
}

Web Storage is deliberately small. localStorage is generally limited to 5 MiB per origin. If a write exceeds the quota, remove only expired keys that your application owns and retry once. Do not call localStorage.clear(): that wipes every key for the origin, including data owned by unrelated parts of the site. For larger or more structured datasets, I would reach for IndexedDB instead.

Preserving Preferences With Migrations

Invalidation is enough when we are happy to reset the user’s state. If we want to preserve it, we can keep an ordered registry of one-time migrations.

The migration below moves an orders table from this shape:

{
  hiddenColumns: ["internalNotes"];
}

to this one:

{ hiddenColumns: ["internalNotes"], columnOrder: [] }

Now we are going back to TS because ILY.

const STORAGE_MIGRATION_PREFIX = "my-app.storage-migration.";

interface StorageMigration {
  /** A unique, permanent ID. Never rename or reuse an ID after release. */
  id: string;

  /** Migrations should be idempotent because an interrupted one may run again. */
  migrate: () => void;
}

interface V1TablePreferences {
  hiddenColumns: string[];
}

function isV1TablePreferences(value: unknown): value is V1TablePreferences {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  const candidate = value as Record<string, unknown>;

  return (
    Array.isArray(candidate.hiddenColumns) &&
    candidate.hiddenColumns.every((column) => typeof column === "string")
  );
}

const STORAGE_MIGRATIONS: StorageMigration[] = [
  {
    id: "orders-table-v1-to-v2",
    migrate: () => {
      const oldKey = "my-app.preferences.orders-table.v1";
      const newKey = "my-app.preferences.orders-table.v2";

      // If the new value already exists, the migration's work is complete.
      // This makes a retry safe if writing the completion flag failed.
      if (localStorage.getItem(newKey) !== null) {
        localStorage.removeItem(oldKey);
        return;
      }

      const oldData = localStorage.getItem(oldKey);

      // A new user has nothing to migrate.
      if (oldData === null) {
        return;
      }

      let parsedData: unknown;

      try {
        parsedData = JSON.parse(oldData);
      } catch {
        // Corrupted data cannot be recovered, so fall back to v2 defaults.
        localStorage.removeItem(oldKey);
        return;
      }

      if (!isV1TablePreferences(parsedData)) {
        localStorage.removeItem(oldKey);
        return;
      }

      const migratedData = {
        hiddenColumns: parsedData.hiddenColumns,
        columnOrder: [],
      };

      // Write the replacement before deleting the old value. localStorage
      // does not give us a transaction across multiple keys.
      localStorage.setItem(newKey, JSON.stringify(migratedData));
      localStorage.removeItem(oldKey);
    },
  },
];

/**
 * Runs pending migrations in order during client-side application startup,
 * before the application reads any persisted state.
 */
export function runStorageMigrations(): boolean {
  for (const { id, migrate } of STORAGE_MIGRATIONS) {
    const migrationKey = `${STORAGE_MIGRATION_PREFIX}${id}`;

    try {
      if (localStorage.getItem(migrationKey) !== null) {
        continue;
      }

      migrate();
      localStorage.setItem(migrationKey, "1");
    } catch (error) {
      console.warn(`Failed to run storage migration "${id}"`, error);

      // Later migrations may depend on this one, so stop and retry on the
      // next application startup instead of continuing out of order.
      return false;
    }
  }

  return true;
}

There are a few details in this example that are easy to miss:

  1. New migrations are appended to the list so their order remains stable.
  2. A migration is marked complete only after it succeeds.
  3. If a migration fails, later migrations do not run on top of a half-migrated state.
  4. Each migration is idempotent, so retrying it is safe.
  5. The new value is written before the old one is removed.
  6. Stored values are validated even when they were originally written by our own app.

Call runStorageMigrations() once during client-side startup, before anything consumes the cached state. If multiple tabs can update the same keys, remember that localStorage does not provide locking or cross-key transactions. Other same-origin tabs receive a storage event, which you can use to refresh their in-memory state if your application needs that behaviour.

That is pretty much it. Version the schema, validate on the way in, fail safely to defaults, and make every migration safe to retry. Happy cachemaxxing :D

Further Reading

Related posts