DEV Community

473185670
473185670

Posted on

localStorage as a Database: How I Built 23 Mental Health Tools with Zero Backend, Zero Signup, Zero Dependencies

When you build 23 interactive tools and there's no backend, no database, no signup flow — where does the user's data go?

Their thought records. Their mood logs. Their panic attack diary. Their ERP tracker entries. The stuff they typed at 2am when they were spiraling.

The answer: localStorage. But not the naive localStorage.setItem('data', JSON.stringify(x)) you see in tutorials. That breaks in production. I know because I shipped it 23 times and hit every edge case.

This is the wrapper pattern that made it work across all 23 tools — in under 40 lines of vanilla JavaScript.

Try the tools live: CBT Toolkit — 23 interactive mental health tools, all running entirely in your browser.

The problem

Each tool needs to:

  • Save entries (thought records, diary entries, mood logs)
  • Load them on page refresh
  • Delete individual entries
  • Export all data as a JSON backup
  • Survive browser crashes, quota limits, and private browsing mode

And it needs to do all of this without a backend, because:

  1. Privacy is non-negotiable. People type their most anxious thoughts into these tools. That data should never touch a server.
  2. Zero infrastructure cost. 23 tools on GitHub Pages. No database to manage, no API to maintain, no auth to implement.
  3. Instant. No network round-trip. Save is synchronous. The UI never waits.

The naive version (and why it breaks)

// What every tutorial shows you
function saveEntry(entry) {
  const data = JSON.parse(localStorage.getItem('entries') || '[]');
  data.push(entry);
  localStorage.setItem('entries', JSON.stringify(data));
}
Enter fullscreen mode Exit fullscreen mode

This works in the demo. Then you ship it and:

  • QuotaExceededError in Safari private browsing mode — setItem throws, entry is lost, user doesn't know
  • SyntaxError when something corrupts the key — JSON.parse throws, the entire tool breaks, white screen
  • No timestamps — entries are unordered, you can't sort the diary
  • No export — user wants to back up their data before clearing browser history, and they can't
  • Silent failures — the save "works" but the data is gone on refresh because incognito mode doesn't persist

I hit all of these. Here's the fix.

The wrapper (the actual code)

This is the pattern used across all 23 tools. It's a tiny DB abstraction — save, load, delete, export, import — that handles the real-world edge cases:

const DB = (function () {
  const PREFIX = 'cbt_'; // namespace to avoid collisions

  function _key(tool) {
    return PREFIX + tool;
  }

  function load(tool) {
    try {
      const raw = localStorage.getItem(_key(tool));
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch (e) {
      // Corrupted data — reset rather than crash the whole tool
      console.warn('DB.load: corrupted key, resetting:', tool, e.message);
      return [];
    }
  }

  function save(tool, entries) {
    try {
      localStorage.setItem(_key(tool), JSON.stringify(entries));
      return true;
    } catch (e) {
      // QuotaExceededError (Safari private mode, or storage full)
      console.warn('DB.save: failed:', tool, e.message);
      return false;
    }
  }

  function add(tool, entry) {
    const entries = load(tool);
    entry.id = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
    entry.created = new Date().toISOString();
    entries.unshift(entry); // newest first
    return save(tool, entries) ? entry : null;
  }

  function remove(tool, id) {
    const entries = load(tool).filter(e => e.id !== id);
    return save(tool, entries);
  }

  function exportAll(tool) {
    return JSON.stringify({ tool: tool, exported: new Date().toISOString(), entries: load(tool) }, null, 2);
  }

  function importJSON(tool, jsonString) {
    try {
      const data = JSON.parse(jsonString);
      if (!Array.isArray(data.entries)) throw new Error('Invalid format');
      return save(tool, data.entries);
    } catch (e) {
      console.warn('DB.import: failed:', e.message);
      return false;
    }
  }

  return { load, save, add, remove, exportAll, importJSON };
})();
Enter fullscreen mode Exit fullscreen mode

That's it. 40 lines. Every tool in the toolkit uses this same module.

The edge cases it handles (and how)

1. QuotaExceededError — Safari private browsing

Safari's private browsing mode sets localStorage to a no-op that throws QuotaExceededError on every setItem. The naive version crashes. The wrapper catches it, returns false, and the tool shows a gentle "Could not save — you may be in private browsing mode" message instead of a white screen.

const saved = DB.add('thought_record', entry);
if (!saved) {
  showNotice('Could not save. You may be in private browsing mode. Your entry is visible below — copy it to keep it.');
}
Enter fullscreen mode Exit fullscreen mode

2. Corrupted JSON — the silent killer

Sometimes a browser extension, a manual devtools edit, or a partial write corrupts a localStorage key. JSON.parse throws SyntaxError. The naive version crashes the entire tool. The wrapper catches it, logs a warning, and returns an empty array — the tool works, just with no history.

This happened to a user. Their browser extension wrote to the same key prefix. Without the try/catch, the tool was bricked.

3. Entry IDs and timestamps

Every entry gets a unique ID (Date.now() + random) and an ISO timestamp. This means:

  • Diary entries can be sorted by date
  • Individual entries can be deleted by ID
  • The insights panel can compute trends (is the user's anxiety decreasing over time?)

Without IDs, delete is "delete the last one" or "delete by index" — both break when the array is reloaded.

4. Export for data portability

Users want to back up their data. Or move it to a new browser. Or send it to their therapist. The exportAll function produces a clean JSON file:

// Wire up an export button
document.getElementById('export-btn').addEventListener('click', () => {
  const json = DB.exportAll('panic_diary');
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'panic-diary-' + new Date().toISOString().slice(0, 10) + '.json';
  a.click();
  URL.revokeObjectURL(url);
});
Enter fullscreen mode Exit fullscreen mode

5. Namespace prefix

The cbt_ prefix prevents key collisions. If two tools are on the same origin (they are — all 23 are on the same GitHub Pages site), cbt_thought_record and cbt_mood_tracker don't step on each other.

The schema (one key per tool)

Each tool stores its entries as a JSON array under one localStorage key:

cbt_thought_record  → [{ id, created, situation, thought, emotion, distortion, reframe, belief }, ...]
cbt_mood_tracker    → [{ id, created, mood, emotion_tags, activities, notes }, ...]
cbt_panic_diary     → [{ id, created, datetime, location, trigger, peak_anxiety, symptoms, thought, belief }, ...]
cbt_erp_tracker     → [{ id, created, trigger, obsession_type, compulsion_resisted, suds_before, suds_after }, ...]
Enter fullscreen mode Exit fullscreen mode

One key per tool. An array of entry objects. No relational joins, no indexes, no migrations. It's a document store with one collection per tool.

Why not one giant key with all tools? Because if one tool's data corrupts, it doesn't take down the other 22. Each tool is isolated.

Why localStorage (not IndexedDB, not a backend)

I considered the alternatives:

Option Why not
IndexedDB Asynchronous API (callback hell without a wrapper), more complex, overkill for <1000 entries per tool. localStorage is synchronous — save() returns immediately, the UI never waits.
Cookies 4KB limit. A thought record diary hits that in ~20 entries. localStorage gives 5-10MB.
Backend + database Privacy (users type their darkest thoughts), infrastructure cost (23 tools = 23 endpoints or a multi-tenant DB), auth complexity (signup flow kills the "no signup" promise).
sessionStorage Cleared when the tab closes. Useless for a diary you want to keep.

localStorage is the boring, correct choice: synchronous, 5-10MB, persists across sessions, zero dependencies, works offline, works in every browser since IE8.

The tradeoff: 5-10MB is the ceiling. A user logging 50 thought records a day for a year hits ~18,000 entries. At ~200 bytes each, that's 3.6MB. It fits — barely. If someone exceeds quota, the wrapper catches it and tells them to export + clear old entries. Not ideal, but a graceful degradation for a free, no-backend tool.

The pattern in action

Here's how a tool wires it up — the Panic Diary's save handler:

function savePanicEntry() {
  const entry = {
    datetime: document.getElementById('datetime').value,
    location: document.getElementById('location').value,
    trigger: document.getElementById('trigger').value,
    peak_anxiety: parseInt(document.getElementById('peak').value),
    symptoms: getCheckedSymptoms(),      // array of symptom strings
    thought: document.getElementById('thought').value,
    belief: parseInt(document.getElementById('belief').value),
  };

  const saved = DB.add('panic_diary', entry);
  if (saved) {
    renderHistory();    // re-render the diary list
    renderInsights();   // recompute aggregate stats
    showNotice('Entry saved.');
  } else {
    showNotice('Could not save — you may be in private browsing mode.');
  }
}

function renderInsights() {
  const entries = DB.load('panic_diary');
  if (entries.length === 0) return;

  const totalAttacks = entries.length;
  const avgPeak = Math.round(entries.reduce((s, e) => s + e.peak_anxiety, 0) / totalAttacks);
  const avgBelief = Math.round(entries.reduce((s, e) => s + e.belief, 0) / totalAttacks);

  // Trend: is peak anxiety decreasing over time?
  const sorted = [...entries].sort((a, b) => a.created.localeCompare(b.created));
  const firstHalf = sorted.slice(0, Math.floor(sorted.length / 2));
  const secondHalf = sorted.slice(Math.floor(sorted.length / 2));
  const firstAvg = avg(firstHalf.map(e => e.peak_anxiety));
  const secondAvg = avg(secondHalf.map(e => e.peak_anxiety));
  const trend = secondAvg < firstAvg ? 'decreasing (CBT is working)' : 'stable or increasing';

  document.getElementById('insights').innerHTML =
    '<p>Attacks logged: <strong>' + totalAttacks + '</strong></p>' +
    '<p>Average peak anxiety: <strong>' + avgPeak + '/100</strong></p>' +
    '<p>Belief in catastrophic thought: <strong>' + avgBelief + '%</strong></p>' +
    '<p>Trend: <strong>' + trend + '</strong></p>';
}
Enter fullscreen mode Exit fullscreen mode

Save, load, compute insights, render. No async, no loading spinners, no network errors. The data is just there.

What I'd do differently at scale

This pattern works for a free, single-user, client-side toolkit. At scale (multi-user, sync across devices, shared data), I'd:

  1. Add IndexedDB as a tier-2 store. localStorage for the latest 100 entries (fast, synchronous UI). IndexedDB for the archive (async, larger capacity). Transparent to the user.
  2. Add a sync layer. Optional opt-in cloud sync (encrypted with a user-held key) for cross-device access. Still no backend database — just a blob store (S3/R2) keyed by encrypted user ID.
  3. Add schema versioning. Right now the schema is implicit (whatever fields the tool writes). A schema_version field + migration function would handle evolving the entry shape without breaking old data.
  4. Use a Web Worker for large exports. Exporting 10,000 entries as JSON on the main thread blocks the UI for ~200ms. A worker keeps it smooth.

But for 23 free tools with no backend? The 40-line wrapper is the right answer. It's boring, it's reliable, and it respects the user's privacy by never letting their data leave the device.

The takeaway

localStorage is a database. Not a good one — no queries, no indexes, no concurrency, 5MB limit. But for client-side tools where privacy is the feature and the data is a personal diary, it's the correct architecture.

The pattern:

  1. Wrap it in a try/catch module that handles quota + corruption
  2. One key per tool (isolation)
  3. Entries are objects with IDs + timestamps (sortable, deletable)
  4. Export to JSON (portability)
  5. Graceful degradation when it fails (don't crash the tool)

No dependencies. No backend. No signup. No privacy risk. Just 40 lines and a try/catch.


The full toolkit is free: 23 interactive CBT tools — thought records, mood tracker, distortion checker, condition-specific guides for anxiety, depression, OCD, panic, burnout, imposter syndrome, and more. All running entirely in your browser.

If you want the structured Notion version (pre-built database views, templates, daily review dashboard): CBT Thought Record Notion Template ($7).

Top comments (0)