Skip to content
JavaScript

4 localStorage JSON State Tests Before Frontend Release

·11 min read
4 localStorage JSON State Tests Before Frontend Release - JavaScript guide from JSON Prettifier

Quick answer: Test how the adapter distinguishes missing and invalid records, migrates recognized older versions, handles storage acquisition and operation failures, and avoids writing temporary defaults during initialization. JSON serialization is only one part of browser persistence. A readable interface can still lose saved preferences if it silently treats every failed read as an empty store.

A deployment updates your JavaScript; it does not automatically update every saved record. The JSON parsing error guide covers syntax diagnosis. These four groups add an explicit persistence policy for a small preference record, with no promise that localStorage is a durable database.

Run the JavaScript blocks together, in order, as one .mjs file. They were executed with Node.js v24.22.1 and controlled storage substitutes. The fixtures test adapter logic, not a browser storage engine. Wikipedia's Web storage overview provides background; the linked platform documentation defines the API behavior discussed here.

1. Does the decoder distinguish a missing key from an invalid record?

Check the raw storage result for null before parsing, then validate the parsed record's supported shape. A missing key, an empty string, JSON null, and a malformed preference object represent different situations. Return an explicit status for each policy outcome so initialization can display defaults without pretending every record was safely loaded.

MDN documents getItem as returning a stored string or null when the key is absent. The example intentionally accepts only two sort values and three page sizes. Those are application choices, not restrictions imposed by JSON or Web Storage.

import assert from "node:assert/strict";

const KEY = "catalog.preferences";
const defaults = () => ({ sort: "asc", pageSize: 25 });
const isObject = value => value !== null && typeof value === "object"
  && !Array.isArray(value);
const isData = value => isObject(value)
  && ["asc", "desc"].includes(value.sort)
  && [10, 25, 50].includes(value.pageSize);
const selectData = value => ({ sort: value.sort, pageSize: value.pageSize });

function decodeCurrent(raw) {
  if (raw === null) return { status: "missing" };
  let record;
  try { record = JSON.parse(raw); }
  catch { return { status: "invalid" }; }
  if (!isObject(record) || !Number.isInteger(record.version)) {
    return { status: "invalid" };
  }
  if (record.version !== 2) return { status: "unsupported" };
  if (!isData(record.data)) return { status: "invalid" };
  return { status: "current", value: selectData(record.data) };
}

assert.equal(decodeCurrent(null).status, "missing");
for (const raw of ["", "null", "[]", "{", '{"version":2,"data":{}}']) {
  assert.equal(decodeCurrent(raw).status, "invalid");
}
const currentRaw = JSON.stringify({ version: 2, data: defaults() });
assert.deepEqual(decodeCurrent(currentRaw), { status: "current", value: defaults() });

The validator projects accepted fields into a new object. Extra stored fields are ignored in this teaching contract; unknown properties are not universally invalid JSON. If preserving them matters to another frontend version, make that requirement explicit instead of copying this projection without review.

Keep the original raw text available to a permitted recovery workflow when useful. Automatically deleting it on every parse failure can destroy evidence or recoverable user work. Conversely, retaining it does not mean the interface must crash repeatedly. A status result lets the UI remain usable while exposing a controlled reset or support action.

For the underlying value distinctions, the JSON null versus undefined guide explains how absence differs from an explicit null. Apply that distinction to this storage contract before choosing fallback behavior, rather than relying on a truthiness check to classify every saved value.

Use JSON Viewer to inspect a sanitized stored record, expand data, and confirm that version is where the adapter expects it. The viewer checks syntax and displays structure; it does not execute this preference policy or establish that a browser successfully saved the record.

Do not put authentication secrets into a sample or support attachment. These fixtures contain harmless display preferences. Choosing local storage for sensitive application state requires a separate security review; a successful JSON decoder is not evidence that the storage location is appropriate for every field.

For a production acceptance table, include a valid envelope with each permitted page size, an integer version stored as a string, a boolean in place of data, and an unknown sort label. State whether a wrong field rejects the whole record or permits partial recovery. The example rejects the record as a unit. Silently salvaging only some fields is another policy, and users should not discover it through a preference that changes unexpectedly after an update.

2. Does a recognized migration preserve meaning without accepting unknown versions?

Validate an older record before converting it into the current shape, and test both values of every transformed field. Keep unsupported versions separate from corrupt records. A migration should produce current data that passes the current validator, while an unrecognized version should remain untouched rather than being guessed into the nearest familiar structure.

The platform stores strings, not an application's evolving schema. The Web Storage specification defines the storage interface; migration semantics belong to your product. Here, version one expresses sort direction as a boolean, while version two uses explicit asc and desc strings.

function decodeRecord(raw) {
  const result = decodeCurrent(raw);
  if (result.status !== "unsupported") return result;
  const old = JSON.parse(raw); // Syntax was already checked above.
  if (old.version !== 1) return result;
  if (!isObject(old.data) || typeof old.data.ascending !== "boolean"
      || ![10, 25, 50].includes(old.data.pageSize)) {
    return { status: "invalid" };
  }
  return { status: "migrated", value: {
    sort: old.data.ascending ? "asc" : "desc",
    pageSize: old.data.pageSize,
  } };
}

for (const ascending of [true, false]) {
  const raw = JSON.stringify({ version: 1, data: { ascending, pageSize: 10 } });
  const migrated = decodeRecord(raw);
  assert.equal(migrated.status, "migrated");
  assert.equal(migrated.value.sort, ascending ? "asc" : "desc");
  const rewritten = JSON.stringify({ version: 2, data: migrated.value });
  assert.deepEqual(decodeRecord(rewritten), { status: "current", value: migrated.value });
}
assert.equal(decodeRecord('{"version":1,"data":{"ascending":"false","pageSize":10}}').status,
  "invalid");
const futureRaw = '{"version":9,"data":{"layout":"future"}}';
assert.equal(decodeRecord(futureRaw).status, "unsupported");

The string false fixture matters because a truthiness conversion would turn it into the wrong direction. Rejecting the wrong type is more predictable than trying to rescue it silently. Include real legacy fixtures from your documented contract when building a production migration, while removing personal information before sharing test cases.

The rewritten record returns current rather than migrating again. That assertion checks a useful stability property, but it does not prove every historical version has a valid migration chain. Test each supported origin version, intermediate failure, and removed field deliberately. Avoid claiming backward compatibility based only on one successful old sample.

This compact demonstration reparses a recognized old record after classification. For larger records, a production decoder can parse once and pass a validated envelope into separate version handlers. Preserve the statuses and acceptance tests while refactoring; the implementation structure is less important than the meaning of accepted and rejected records.

Also distinguish successful conversion in memory from successful persistence. A migration can supply usable preferences even when saving the new representation fails. Keep that distinction visible so a reload does not surprise the user with old settings after the UI announced that everything had been permanently upgraded.

Plan the rollback case before removing old migration code. A user may reopen an older cached frontend after another tab has written the new format. Versioning the key itself can isolate incompatible formats, but then the application needs a policy for discovering, retaining, and eventually removing old keys. Keeping one versioned envelope has different tradeoffs. Choose deliberately, test the rollback path, and avoid presenting either naming strategy as a universal solution to every multi-version deployment.

3. Are storage acquisition, read failures, and write failures handled separately?

Acquire storage inside the protected operation, then handle read and write failures without claiming persistence succeeded. The localStorage getter itself can fail before getItem runs. Test that boundary alongside failing methods, and keep a failed save distinguishable from invalid input so the interface can explain whether preferences are unsaved or unsupported.

MDN's localStorage reference documents access exceptions. The setItem reference describes storage writes and quota failures. The fixtures below inject exceptions deliberately; they do not fill a real browser quota or identify which browsing modes allow storage.

function load(getStorage) {
  try { return decodeRecord(getStorage().getItem(KEY)); }
  catch { return { status: "unavailable" }; }
}

function save(getStorage, data) {
  if (!isData(data)) return { ok: false, reason: "invalid" };
  const text = JSON.stringify({ version: 2, data: selectData(data) });
  try {
    getStorage().setItem(KEY, text);
    return { ok: true };
  } catch {
    return { ok: false, reason: "unavailable" };
  }
}

const denied = () => { throw new DOMException("Blocked", "SecurityError"); };
assert.equal(load(denied).status, "unavailable");
assert.deepEqual(save(denied, defaults()), { ok: false, reason: "unavailable" });
assert.equal(load(() => ({ getItem() { throw new Error("read failed"); } })).status,
  "unavailable");
const fullStore = { raw: currentRaw, attempts: 0, setItem() {
  this.attempts++;
  throw new DOMException("Full", "QuotaExceededError");
} };
const full = () => fullStore;
assert.deepEqual(save(full, { sort: "desc", pageSize: 50 }),
  { ok: false, reason: "unavailable" });
assert.equal(fullStore.attempts, 1);
assert.equal(fullStore.raw, currentRaw);
assert.deepEqual(save(denied, { sort: "sideways", pageSize: 25 }),
  { ok: false, reason: "invalid" });

In the browser, pass a supplier such as a function returning window.localStorage when the client lifecycle permits it. Obtaining that property before calling load would move acquisition outside the catch boundary. A server-rendered component also needs an explicit client initialization path; these helpers are not a framework hydration implementation.

The retained-value assertion represents the substitute's unchanged state. It does not measure browser durability or atomicity across several keys. In a real integration test, seed an existing record, force a write failure through the supported test mechanism, and verify both the stored text and the visible unsaved indicator.

Avoid promising a particular capacity number in application logic. Availability and persistence policy are environmental concerns; a successful tiny probe cannot guarantee a later larger write. Attempt the intended operation, handle its result, and test the interface's recovery behavior rather than treating one feature-detection check as permission to ignore later exceptions.

The small save helper serializes only validated plain preference fields. It is not a general serializer for arbitrary objects, getters, cyclic graphs, or custom classes. If your application saves broader values, cover serialization failures and projections with separate tests before adapting this helper.

When persistence fails, let the interface distinguish an applied in-memory change from a saved change. A user can continue sorting the current table even when saving preferences is unavailable, provided the product explains that reloading may lose the choice. Retry through a deliberate action or a bounded policy; an automatic retry on every render can turn one blocked write into repeated errors. Record safe diagnostic categories instead of logging the entire stored payload or assuming every exception is a quota problem.

4. Does initialization preserve unsupported records instead of saving temporary defaults?

Assert that loading preferences performs no writes, including when the record is unsupported, invalid, or temporarily unreadable. Rendering defaults is a display decision, not authorization to replace saved state. Permit persistence through an explicit supported session policy, and test that an older frontend cannot overwrite a future record merely by mounting.

This is an application lifecycle rule rather than a special storage API feature. A common failure sequence is load, fall back, then automatically save the fallback. All three steps can work exactly as coded while destroying a record the current bundle did not understand.

function memory(raw) {
  return {
    raw, writes: 0,
    getItem() { return this.raw; },
    setItem(_key, text) { this.raw = text; this.writes++; },
  };
}

function initialize(getStorage) {
  const result = load(getStorage);
  const editable = ["missing", "current", "migrated"].includes(result.status);
  return { status: result.status, value: result.value ?? defaults(), editable };
}

function saveFromSession(getStorage, session, data) {
  if (!session.editable) return { ok: false, reason: "recovery-required" };
  return save(getStorage, data);
}

for (const raw of [futureRaw, "{", null, currentRaw]) {
  const store = memory(raw);
  const session = initialize(() => store);
  assert.equal(store.writes, 0);
  assert.equal(store.raw, raw);
  if (session.status === "unsupported" || session.status === "invalid") {
    assert.equal(saveFromSession(() => store, session, defaults()).reason,
      "recovery-required");
    assert.equal(store.raw, raw);
  }
}
const store = memory(currentRaw);
const session = initialize(() => store);
assert.deepEqual(saveFromSession(() => store, session, { sort: "desc", pageSize: 10 }),
  { ok: true });
assert.equal(store.writes, 1);
assert.deepEqual(decodeRecord(store.raw).value, { sort: "desc", pageSize: 10 });
const blockedSession = initialize(denied);
assert.equal(blockedSession.status, "unavailable");
assert.equal(blockedSession.editable, false);
assert.equal(saveFromSession(denied, blockedSession, defaults()).reason,
  "recovery-required");
const legacyStore = memory('{"version":1,"data":{"ascending":false,"pageSize":50}}');
const legacySession = initialize(() => legacyStore);
assert.equal(legacySession.status, "migrated");
assert.equal(legacyStore.writes, 0);
assert.deepEqual(legacySession.value, { sort: "desc", pageSize: 50 });
console.log("All four localStorage JSON state test groups passed");

For invalid or future state, provide a deliberate recovery path appropriate to the product. A reset action can explain what it will replace, while a support export can preserve a sanitized copy when allowed. Do not turn a generic catch block into an unexplained destructive reset.

This session flag is not concurrency control. Another tab can modify the key after initialization, and an older tab can keep using its earlier editable result. Test multi-tab behavior separately and choose a conflict policy; a localStorage read followed by a write is not an atomic compare-and-swap operation. The API concurrency guide explains why snapshot assumptions need explicit coordination in other persistence contexts too.

Finish with browser integration checks: seed each supported record, mount the real application, inspect controls, reload, and verify the intended saved representation. Repeat with blocked access and an unsupported future version. Controlled fixtures make the adapter's decisions reviewable; the browser tests establish that the frontend calls it at the right time and reports save failures honestly.

Inspect the number and timing of writes in those integration checks. The current policy performs zero writes during initialization and one write after an explicit supported save. A framework effect that immediately persists its initial defaults would violate that contract even if these standalone helpers remain correct. Include a slow initialization scenario so a user action cannot race a late load and disappear. If the product intentionally autosaves, define when loading is complete and which record statuses authorize that first write.

Frequently Asked Questions

Is a missing storage key the same as stored JSON null?
No. getItem returns JavaScript null for a missing key, while a stored JSON null is the string null before parsing. Decide how each case fits the application's record contract. Check the raw return value before parsing so missing preferences remain distinguishable from a present record with an invalid shape.
Does JSON.parse validate a stored preference record?
No. JSON.parse checks JSON syntax and creates a JavaScript value. Valid JSON can still contain an array, null, unsupported version, or fields with the wrong types. Validate the record's structure and supported version separately before using it to initialize controls or deciding that it is eligible for migration.
Can reading window.localStorage throw before getItem runs?
Yes. Accessing the localStorage property can throw a SecurityError under relevant browser restrictions. Put storage acquisition inside the protected operation rather than obtaining the object before entering try. Test acquisition, getItem, and setItem failures separately, because handling a write exception does not establish that initialization survives a blocked read.
Should old frontend code overwrite a future record version?
An application that cannot interpret a record's future version should preserve it unless an explicit recovery policy authorizes replacement. Displaying temporary defaults does not make those defaults a safe saved replacement. Keep unsupported state distinguishable from a missing key, disable automatic persistence, and offer an intentional recovery path when appropriate.
Do these localStorage tests prove browser persistence works?
No. Controlled Node fixtures verify the adapter's branching, migrations, and response to simulated exceptions. They do not measure browser quotas, origin restrictions, eviction, tab events, or reload behavior. Add integration tests in supported browsers that exercise the real storage object and the actual frontend lifecycle before relying on persistence in production.

Can you inspect the stored record?

Paste a sanitized JSON record into the viewer to inspect its version and nested fields before running the adapter tests.

Open JSON Viewer