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'
});
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
};
}
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);
}
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' });
}
}
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
- Start with Dexie.js from day one — I prototyped with localStorage first, then migrated. Wasted 2 days.
-
Schema versioning early — Dexie's
version()API makes migrations painless, but only if you plan for them. - 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
- I Built a Finance App With Zero Backend — Browser-Only Architecture
- Why I Chose IndexedDB Over a Backend
- → You are here — Storing Financial Data in the Browser: IndexedDB + Dexie.js Guide
- Share Your Web App State via URL — No Backend Required
- 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)