DEV Community

Cover image for Storing Financial Data in the Browser: An IndexedDB + Dexie.js Architecture Guide
MaxxMini
MaxxMini

Posted on • Edited on

Storing Financial Data in the Browser: An IndexedDB + Dexie.js Architecture Guide

Most budget apps store your financial data on someone else's server. I wanted mine to stay on my device — forever. Here's the architecture I built with IndexedDB and Dexie.js.

Why Not localStorage?

localStorage is tempting — simple key-value, synchronous, available everywhere. But for financial data:

Feature localStorage IndexedDB
Storage limit ~5 MB ~1 GB+
Data types Strings only Objects, Blobs, Arrays
Querying Manual parse Indexes + cursors
Transactions ✅ ACID
Concurrent tabs Race conditions Transaction isolation

Financial data grows fast — 500 transactions/month × 5 years = 30,000 records. localStorage chokes. IndexedDB handles it natively.

The Schema

I used Dexie.js as the IndexedDB wrapper. Here's the core schema:

import Dexie from 'dexie';

const db = new Dexie('BudgetDB');

db.version(1).stores({
  categories: '++id, name, type, parentId',
  budgetItems: '++id, categoryId, amount, frequency',
  transactions: '++id, categoryId, amount, date, source',
  snapshots: '++id, createdAt, data'
});
Enter fullscreen mode Exit fullscreen mode

Why These Tables?

  • categories: Hierarchical budget categories (Housing → Rent, Utilities, Insurance)
  • budgetItems: Planned amounts per category ("I want to spend $500/mo on groceries")
  • transactions: Actual spending records (imported from bank CSV/XLSX)
  • snapshots: Point-in-time exports for version history

Plan vs Reality: The Core Query

The killer feature is comparing planned budget vs actual spending:

async function getBudgetDrift(categoryId: string, month: Date) {
  const planned = await db.budgetItems
    .where('categoryId')
    .equals(categoryId)
    .first();

  const startOfMonth = new Date(month.getFullYear(), month.getMonth(), 1);
  const endOfMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0);

  const actual = await db.transactions
    .where('categoryId')
    .equals(categoryId)
    .and(t => t.date >= startOfMonth && t.date <= endOfMonth)
    .toArray();

  const totalSpent = actual.reduce((sum, t) => sum + t.amount, 0);
  const drift = totalSpent - (planned?.amount || 0);

  return {
    planned: planned?.amount || 0,
    actual: totalSpent,
    drift,
    driftPercent: planned?.amount ? (drift / planned.amount) * 100 : 0
  };
}
Enter fullscreen mode Exit fullscreen mode

When drift exceeds a threshold (e.g., 20% over budget), the UI shows a warning. No server needed — the comparison runs entirely in the browser.

Importing Bank Data

The trickiest part: banks export data in wildly different formats. I used SheetJS to handle CSV, XLSX, and ODS:

import * as XLSX from 'xlsx';

async function importFile(file: File) {
  const buffer = await file.arrayBuffer();
  const workbook = XLSX.read(buffer);
  const sheet = workbook.Sheets[workbook.SheetNames[0]];
  const rows = XLSX.utils.sheet_to_json(sheet);

  // Auto-detect columns: look for amount-like and date-like fields
  const amountCol = detectAmountColumn(rows[0]);
  const dateCol = detectDateColumn(rows[0]);

  const transactions = rows.map(row => ({
    amount: parseFloat(row[amountCol]),
    date: new Date(row[dateCol]),
    description: row[detectDescColumn(rows[0])] || '\,
    source: file.name
  }));

  await db.transactions.bulkAdd(transactions);
}
Enter fullscreen mode Exit fullscreen mode

The detectAmountColumn function checks for column names like "Amount", "금액", "Betrag", "Montant" — supporting 14 card issuers across languages.

Data Portability: The Exit Strategy

A local-first app that traps your data is worse than a cloud app. Full export is non-negotiable:

async function exportAll(format: 'json' | 'csv' | 'xlsx') {
  const data = {
    categories: await db.categories.toArray(),
    budgetItems: await db.budgetItems.toArray(),
    transactions: await db.transactions.toArray()
  };

  if (format === 'json') {
    return JSON.stringify(data, null, 2);
  }

  if (format === 'xlsx') {
    const wb = XLSX.utils.book_new();
    Object.entries(data).forEach(([name, rows]) => {
      XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), name);
    });
    return XLSX.write(wb, { type: 'array' });
  }
}
Enter fullscreen mode Exit fullscreen mode

JSON for developers, CSV for spreadsheets, XLSX for everyone else. The goal: your data should be readable in 10 years, even if this app disappears.

Performance: 30K Records

With proper indexes, Dexie.js handles 30,000 transactions without breaking a sweat:

  • Initial load: ~50ms (indexed query, not full table scan)
  • Monthly aggregation: ~15ms (.where().between() on date index)
  • Full export: ~200ms (bulk read + SheetJS serialization)

The key: never load all records at once. Query by month, category, or date range. IndexedDB indexes make this fast.

What I'd Do Differently

  1. Start with Dexie.js from day one — I prototyped with localStorage first, then migrated. Wasted 2 days.
  2. Schema versioning early — Dexie's version() API makes migrations painless, but only if you plan for them.
  3. Test with real bank exports — Mock data doesn't catch encoding issues (UTF-8 BOM, Windows-1252, date format chaos).

Try It

I built DonFlow using this architecture — a plan-vs-reality budget tracker that runs entirely in your browser. Zero backend, zero tracking, zero accounts.

Source on GitHub · Architecture docs


Have you built anything with IndexedDB? I'd love to hear about your schema design — especially if you've dealt with migrations on production data.



📖 Series: Building a Finance App With No Server

  1. I Built a Finance App With Zero Backend — Browser-Only Architecture
  2. Why I Chose IndexedDB Over a Backend
  3. → You are here — Storing Financial Data in the Browser: IndexedDB + Dexie.js Guide
  4. Share Your Web App State via URL — No Backend Required
  5. What I Learned After 2 Weeks of Building

📘 Free Resource

If you are building with a $0 budget, I wrote a playbook about what works, what doesn't, and how to think about the $0 phase.

📥 The $0 Developer Playbook — Free (PWYW)

Want the deep dive? The Extended Edition ($7) includes a 30-day launch calendar, 5 copy templates, platform comparison matrix, and revenue math calculator.

Top comments (0)