Versioning localStorage Without Losing Anyone's Saved Game
Local-first means the user's data is older than your code. Namespacing, a version field, migrations, and the three failure modes that will corrupt a save if you don't handle them.
If your app stores state on the device, you eventually ship a change that makes the stored shape wrong. The user's data was written by last month's code and is being read by today's.
Server-side you'd run a migration and be done. Local-first you can't — the data is on thousands of devices, some of which won't open the app for six months. The migration has to happen at read time, on the user's device, possibly across several versions at once.
This is how OmniPlay handles it, including the failure modes that cost us a saved game or two before we handled them.
Namespace and version every key
Two rules from the start.
Namespace everything. localStorage is shared across the whole origin. If
you ever host anything else on the same domain — a marketing page, a second
product, a third-party widget — an unprefixed key like settings is a
collision waiting to happen.
Put the version inside the value, not the key. Versioning the key
(save_v3) orphans old data: you can no longer find save_v2 to migrate it,
and it sits there forever consuming quota. Versioning the value means you
always know where to look.
const NAMESPACE = "omniplay";
const key = (name) => `${NAMESPACE}:${name}`;
function write(name, data, version) {
localStorage.setItem(key(name), JSON.stringify({ version, data }));
}Migrate on read, one step at a time
The instinct is to write a migration from every old version to current. That's O(n²) migrations and they rot.
Instead write each migration as a single step — v1→v2, v2→v3 — and chain them:
const MIGRATIONS = {
1: (data) => ({ ...data, houseRules: DEFAULT_RULES }), // v1 → v2
2: ({ tokens, ...rest }) => ({ ...rest, coins: tokens }), // v2 → v3
};
function migrate(stored, currentVersion) {
let { version, data } = stored;
while (version < currentVersion) {
const step = MIGRATIONS[version];
// An unknown version means data from a build we don't recognise —
// possibly a newer one, if the user has two tabs on different deploys.
if (!step) return null;
data = step(data);
version += 1;
}
return data;
}Each migration is small, written once, and never touched again. A save from four versions ago walks forward through four functions.
Note the null return. Refusing to load is a valid outcome, and it's much
better than loading something half-understood. A discarded save costs the user
one game. A corrupt save loaded into a rules engine produces a board that can't
be played and a bug report nobody can reproduce.
The three things that will actually break
Migrations are the interesting part. These are the parts that bite.
1. localStorage can throw on read and write
Not just when full. In Safari's private mode it has historically thrown on any
write. If a user has disabled site data, even reading throws a
SecurityError. If the origin is over quota, setItem throws
QuotaExceededError.
Every access goes through a wrapper that treats storage as an optional capability:
export function read(name) {
try {
const raw = localStorage.getItem(key(name));
return raw ? JSON.parse(raw) : null;
} catch {
// Unavailable, or unparseable. Either way: no saved state.
return null;
}
}The app has to work with storage entirely absent. That sounds like a big ask and isn't — it just means "no saved game", which is a state you already handle for first-time visitors.
2. JSON.parse on corrupt data
Storage can be edited by hand, truncated by a crash mid-write, or written by a
browser extension. JSON.parse throwing inside a React render is an unmounted
app and a white screen — a much worse outcome than a lost save. The try
above covers it, but it has to wrap the parse, not just the getItem.
3. Two tabs, two versions
The one we didn't anticipate. A user has the game open, we deploy, they open a second tab. Now v3 code and v4 code are both writing to the same keys.
The version check catches the dangerous direction: v3 code reading a v4 save
finds a version higher than it knows, has no migration for it, and refuses to
load rather than guessing. That's exactly what the if (!step) return null
branch is for — it isn't only about ancient data.
Write on every change, not on a timer
A saved game that's thirty seconds stale is nearly as bad as no saved game, because the moment people actually lose a session is a crash or an accidental close — precisely when your debounce timer hasn't fired.
OmniPlay writes the full game state synchronously on every engine transition. This sounds expensive and isn't measurable: the state is a few kilobytes of JSON, serialised a few times a minute.
The rule scales by size, not by frequency. If your state is large enough that serialising it is visible, the fix is to store less, or move to IndexedDB — not to write good data less often.
Know what you're not storing
The last discipline is deciding what shouldn't persist.
OmniPlay stores the engine state — whose turn it is, where the coins are, the house rules in force. It doesn't store animation state, the current dice rotation, whether a dialog is open, or anything derived from the state.
Two reasons. Derived data is a second source of truth that can disagree with the first. And transient UI state restored from storage is uncanny — a resumed game that comes back mid-animation with a dialog open feels broken, even though technically nothing was lost.
Persist the state your logic needs to continue. Let everything else start fresh.