DEV Community

Cover image for Persisting User Data in a No-Backend HTML App (localStorage Patterns)
PromptMaster
PromptMaster

Posted on

Persisting User Data in a No-Backend HTML App (localStorage Patterns)

Persisting User Data in a No-Backend HTML App (localStorage Patterns)

A single-file offline tool has a hard problem: how do you remember anything between sessions with no backend? The answer is localStorage — but naively used, it throws in exactly the environments where a downloaded file often runs. Here's a pattern that persists user data reliably and degrades gracefully when it can't.

The problem with naive localStorage

localStorage.setItem throws in several real situations: private browsing, certain sandboxed contexts, and some configurations of file:// origins — which is precisely how a downloaded HTML tool gets opened. If your save logic isn't wrapped, one throw takes down the feature (or the whole script).

The pattern: wrap and fall back

Wrap every storage call, and fall back to an in-memory object when it throws. The feature keeps working within the session even when persistence isn't available:

const MEM = {};
function store(k, v){
  try { localStorage.setItem(k, v); }
  catch(e) { MEM[k] = v; }
}
function load(k){
  try {
    const x = localStorage.getItem(k);
    if (x !== null) return x;
  } catch(e) {}
  return (k in MEM) ? MEM[k] : null;
}
Enter fullscreen mode Exit fullscreen mode

Now store/load never throw. Best case, data persists across sessions. Worst case, it persists for the current session. Either way the UI never breaks.

Serializing collections

Favorites are a Set; history is an array. Serialize on write, parse on read, and guard the parse:

let favSet = new Set();
try { favSet = new Set(JSON.parse(load("favs") || "[]")); } catch(e) {}

function saveFavs(){ store("favs", JSON.stringify([...favSet])); }
Enter fullscreen mode Exit fullscreen mode

The || "[]" and the try/catch mean a missing or corrupted key yields an empty set instead of a crash.

Capping growth

History that grows forever will eventually hit storage quotas. Cap it on write:

function pushHist(txt){
  hist = [txt, ...hist.filter(h => h !== txt)].slice(0, 20);
  store("hist", JSON.stringify(hist));
}
Enter fullscreen mode Exit fullscreen mode

De-dupe (remove existing copy), prepend (newest first), slice (cap at 20). One line does all three.

The UX caveat worth documenting

localStorage is keyed to the file's origin/path. If the user moves the file, the data may not follow. Worth telling users to keep the file in a stable location — a small note that prevents a "my favorites vanished" support ticket.

See it working

The free Seedance Prompt Composer demo uses exactly this pattern for its history — open it, copy a few prompts, reload, and they're still there. Crack open the source to see the fallback in context.

Try the free demo →

Top comments (0)