Web Storage

Version and validate stored JSON

Serialize a small object deliberately, reject malformed or outdated data, and migrate its shape instead of trusting persisted strings.

Web Storage only stores strings. When you need a small object, you serialize it with JSON.stringify() and parse it back with JSON.parse(). That gives you structure. It does not give you any guarantee that what you read is valid.

Why the data goes bad

The string you wrote last month was written by last month’s code. Since then you may have renamed a field, added a required one, or changed a type. The user still has the old string.

And it’s not only your code that writes there. Any script on the origin can change it. A browser extension can. The user can, in DevTools, in two seconds. A single edit to {"theme":"dark" leaves broken JSON, and JSON.parse() throws at startup. If nothing catches that, the page never finishes initializing.

Version, parse, check, fall back

Four small steps make this safe. Add a version field to the stored object. Parse inside try...catch. Check the fields you’re about to use. Fall back to a known default when anything is off.

Here is the settings loader for the field-notes app:

const raw = localStorage.getItem('field-notes:settings')
let settings = { version: 1, theme: 'light' }

try {
  const saved = JSON.parse(raw ?? 'null')
  if (saved?.version === 1 && ['light', 'dark'].includes(saved.theme)) {
    settings = saved
  }
} catch {}

Let’s read it. settings starts as the default. If the key is missing, raw is null, we parse the string 'null', get null, and the if fails quietly. If the JSON is broken, JSON.parse() throws and the empty catch keeps the default. If the version is wrong or the theme isn’t in the allowlist, the default stays too.

Only when everything checks out do we use the saved object. There’s no path where settings ends up undefined or half-filled.

Migrating a shape

When you change the shape, bump the version and decide what happens to older data. For a replaceable value like settings, discarding is fine: version 1 data falls through the check and the user gets defaults once.

When the data is worth keeping, migrate before the check:

if (saved?.version === 1) {
  saved = { version: 2, theme: saved.theme, fontSize: 'medium' }
}

Then validate the version 2 shape as usual. Test this with a real version 1 string, not one you type fresh.

Not for tokens

One rule I never bend: don’t store authentication tokens in Web Storage. Everything we said about extensions and DevTools applies, and so does injected JavaScript. One XSS hole and the attacker reads the token and sends it home. A session cookie with HttpOnly is out of reach for scripts. Use that instead.

Test the four cases

Save valid settings and reload: they apply. Edit the value in DevTools to malformed JSON, reload, and confirm defaults apply with no console error. Change it to {"version":9,"theme":"dark"}: defaults again. Set a valid version with "theme":"purple": still defaults. In every case the interface should render completely. A half-initialized page is the bug we’re preventing.

Lesson completed