DEV Community

Cover image for Your Financial Data Should Live on Your Device. Here Is the Architecture That Makes That Possible.
Emmanuel
Emmanuel

Posted on

Your Financial Data Should Live on Your Device. Here Is the Architecture That Makes That Possible.

How a carefully layered architecture delivers instant offline access, end-to-end privacy, and optional cloud sync, without asking you to choose between them.

Estimated reading time: 10 minutes


The Privacy Paradox of Financial Apps

Every financial app on the market asks you the same question on page one: "Create an account." Before you can track a single expense, you hand over an email address. Before you can set a savings goal, your data lands on someone else's server. You are trusting that company's security practices, their terms of service, their business model, and their continued existence — all to do basic arithmetic with your own money.

Most apps treat this as inevitable. A login wall is the cost of admission. Your data goes to their database. You hope for the best.

Talliofi refuses that deal. Your data never leaves your device unless you explicitly ask it to. No account. No telemetry. No analytics. If you want cloud sync across devices, you bring your own Supabase instance. If you do not, everything stays local. Forever.

This sounds simple in a pitch deck. In code, it requires an architecture that most web apps never need to consider. This article is a complete walkthrough of how we built it.


What "Local-First" Actually Means

The local-first software movement, articulated by Ink & Switch in 2019, makes one core argument: the best software keeps your data on your device and treats sync as a convenience, not a requirement. The app works on a plane. It works when your connection drops mid-session. It works ten years from now, even if the company behind it disappears.

For Talliofi, local-first is not a philosophy; it is a set of concrete engineering constraints:

  • No login required to use the full app. Open it, enter data, close the tab, come back next month. Everything is there.
  • The device is the source of truth. IndexedDB, not a server, holds the canonical state.
  • Sync is additive, not required. The app does not degrade without it.

This inverts the standard web app model. In a typical application, the server is the source of truth and the client is a view layer. In Talliofi, the client is the source of truth. The server, if present at all, is a backup.


Layer 1: IndexedDB as the Source of Truth

Talliofi uses Dexie, a thin TypeScript-friendly wrapper over IndexedDB, as its primary database. The schema spans 11 tables:

export class TalliofiDatabase extends Dexie {
  plans!: Table<Plan, string>;
  expenses!: Table<ExpenseItem, string>;
  goals!: Table<Goal, string>;
  assets!: Table<Asset, string>;
  liabilities!: Table<Liability, string>;
  snapshots!: Table<MonthlySnapshot, string>;
  netWorthSnapshots!: Table<NetWorthSnapshot, string>;
  changelog!: Table<ChangeLogEntry, string>;
  recurringTemplates!: Table<RecurringTemplate, string>;
  attachments!: Table<ExpenseAttachment, string>;
  vault!: Table<EncryptedVaultRecord, string>;
}
Enter fullscreen mode Exit fullscreen mode

The schema is versioned. Dexie runs migrations sequentially when the schema version increases:

db.version(1).stores({ plans: 'id, name', expenses: 'id, planId', /* ... */ });
// ...
db.version(9).stores({
  // Adds compound index for date-range queries — critical for reports
  expenses: 'id, planId, [planId+transactionDate]',
});
Enter fullscreen mode Exit fullscreen mode

That [planId+transactionDate] compound index in version 9 is a good example of performance-driven schema evolution. Expense reports filter by plan and date range constantly. Without the compound index, every date-range query scans all expenses for a plan. With it, IndexedDB finds the exact range in a single lookup. Schema versions let you add this kind of optimization incrementally, without a big-bang migration.

The result: opening Talliofi after a week away feels instant. No API call, no loading spinner, no authentication handshake. IndexedDB serves data in milliseconds.


Layer 2: TanStack Query as a Smart Cache, Not the Source of Truth

Here is the architectural distinction that most React apps get wrong: TanStack Query is a cache, not a database.

In a typical React + React Query app:

Component -> useQuery -> fetch from API -> display
Enter fullscreen mode Exit fullscreen mode

In Talliofi:

Component -> useQuery -> read from Dexie -> display
Enter fullscreen mode Exit fullscreen mode

The query function talks to IndexedDB, not a network endpoint. TanStack Query still earns its keep: caching, deduplication, background refresh, and stale-time management are genuinely useful even when the data source is local:

export function useExpensesByDateRange(
  planId: string | undefined,
  startDate: string,
  endDate: string,
) {
  return useQuery({
    queryKey: queryKeys.expensesRange(planId!, startDate, endDate),
    queryFn: () =>
      getExpenseRepo().getByPlanIdAndDateRange(planId!, startDate, endDate),
    enabled: !!planId && !!startDate && !!endDate,
    staleTime: PLAN_DATA_STALE_TIME_MS,
  });
}
Enter fullscreen mode Exit fullscreen mode

The staleTime constant means Talliofi does not re-read from IndexedDB on every render. Fresh data serves from the in-memory cache. When a mutation writes to IndexedDB, it invalidates the relevant query keys, triggering a re-read. The component always shows the latest data without polling.

This separation is also what makes the next layer possible.


Layer 3: The Repo Router, One Interface, Three Backends

The central abstraction that makes local-first + optional sync work is the repo router. Every data access in the entire app goes through a function like this:

function isCloudMode(): boolean {
  const mode = useSyncStore.getState().storageMode;
  return mode === 'cloud' || mode === 'encrypted';
}

export function getExpenseRepo(): typeof expenseRepo {
  return isCloudMode() ? supabaseExpenseRepo : expenseRepo;
}
Enter fullscreen mode Exit fullscreen mode

Components and hooks never import a specific repo directly. They call getExpenseRepo(). The function returns the right implementation based on the user's chosen storage mode. Three modes exist:

Mode Backend When to use
local Dexie only Privacy-first, no cloud account needed
cloud Supabase (unencrypted) Multi-device sync, standard cloud trust
encrypted Supabase (AES-256-GCM) Multi-device sync, zero-knowledge privacy

The Dexie repo and the Supabase repo implement the same interface. Adding a new entity type means writing a new Dexie repo, a new Supabase repo, and registering both in the router. The components never change.


The Data Flow

User action (add expense)
        |
        v
useCreateExpense() hook
        |
        v
getExpenseRepo() --> local?  --> expenseRepo (Dexie)
                 \-- cloud?  --> supabaseExpenseRepo (Supabase)
        |
        v
TanStack Query cache invalidated
        |
        v
UI re-renders with fresh data
        |
        v (async, fire-and-forget)
changelog table --> sync engine --> Supabase (on next push)
Enter fullscreen mode Exit fullscreen mode

The sync engine runs in the background. A successful write to Dexie also writes to the local changelog. The sync engine periodically pushes changelog entries to Supabase and pulls remote changes. The UI never waits for sync to complete, local writes are instant.


Layer 4: The Sync Engine, Changelog Over Full-Table Sync

Syncing all data on every cycle works for small datasets but collapses as data grows. Talliofi uses a changelog-based approach instead.

Every create, update, and delete operation appends an entry to a local changelog table:

interface ChangeLogEntry {
  id: string;
  planId: string;
  entityType: 'expense' | 'bucket' | 'plan' | 'goal' | 'asset' | 'liability';
  entityId: string;
  operation: 'create' | 'update' | 'delete';
  timestamp: string;
  payload?: string; // Serialized entity data (encrypted if mode is 'encrypted')
}
Enter fullscreen mode Exit fullscreen mode

On sync, the engine pushes only entries newer than the last sync watermark, a timestamp stored per plan. On pull, it fetches remote entries newer than the watermark and applies them locally.

Conflict resolution uses last-writer-wins with a special case for deletes:

if (row.operation === 'delete' && localEntry) {
  await table.delete(row.entity_id); // Delete always wins
} else if (localEntry && localEntry.timestamp >= row.timestamp) {
  continue; // Local is newer, skip remote
} else {
  await table.put(entity); // Apply remote entry
}
Enter fullscreen mode Exit fullscreen mode

This is a deliberate trade-off worth naming. Last-writer-wins is simple and predictable, but concurrent edits from two devices will produce a winner and a loser. For a personal finance app used primarily on one device at a time, this is exactly the right call. CRDTs or operational transforms would add enormous complexity for a conflict profile that almost never occurs in practice.


Layer 5: End-to-End Encryption with Web Crypto API

The encrypted storage mode goes further: payloads are encrypted client-side before they ever reach Supabase. The server stores opaque ciphertext. Even a full database breach yields nothing useful.

The implementation uses only the browser's native Web Crypto API, no external cryptography libraries:

export async function deriveKey(
  password: string,
  salt: Uint8Array,
): Promise<CryptoKey> {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    'PBKDF2',
    false,
    ['deriveKey'],
  );
  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt,
      iterations: 600_000, // OWASP recommended minimum for SHA-256
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}
Enter fullscreen mode Exit fullscreen mode

The encrypted payload structure:

export interface EncryptedPayload {
  iv: string;         // Base64-encoded random IV (unique per encryption)
  ciphertext: string; // Base64-encoded AES-GCM ciphertext (includes auth tag)
  salt: string;       // Base64-encoded salt for key derivation
}
Enter fullscreen mode Exit fullscreen mode

Four design decisions anchor this layer:

600,000 PBKDF2 iterations. OWASP's current recommendation for PBKDF2-SHA-256. This makes brute-force attacks on the password computationally expensive. Key derivation takes a noticeable moment on first login. We accept that cost.

AES-GCM, not AES-CBC. GCM provides authenticated encryption, decryption verifies the ciphertext has not been tampered with. If an attacker modifies stored data, decryption fails loudly rather than silently producing garbage. This matters when untrusted network paths are involved.

No external crypto libraries. The Web Crypto API ships in every modern browser, is implemented in C++ rather than JavaScript, and carries zero supply-chain risk. Depending on crypto-js for security-critical code adds unnecessary attack surface.

"Bring Your Own Supabase." The user provides their own Supabase project URL and anon key. Talliofi never touches a server we control. The user owns the entire backend. This is the strongest possible privacy guarantee short of no sync at all.


The UX Result

The architecture produces a specific, demonstrable user experience:

  • First load: Fully functional within 200ms. No API call required.
  • Offline use: Identical to online use. Adding, editing, deleting data, rendering reports — all local.
  • Return after absence: Open the app after a week and see your data instantly, then sync in the background if enabled.
  • No account, no friction: New users start planning immediately. Onboarding never asks for an email address.

This experience is only possible because IndexedDB is the source of truth and the network is a sync layer, not a dependency.


What This Architecture Costs

No free lunch. Local-first has real trade-offs, and pretending otherwise would be dishonest.

Storage is bounded. IndexedDB limits vary by browser and device. For a personal finance app with text-based records, this is not a practical concern, years of expenses fit in a few megabytes. Receipt images are a different story. Talliofi stores attachments as base64, which works but is inefficient. A blob-storage backend would be cleaner.

Sync conflicts are possible. Two devices editing the same expense simultaneously will resolve by timestamp. Most users will never notice. Power users who genuinely edit from two devices at once will occasionally see a stale value overwritten. The app surfaces sync status but does not expose conflict details to the user.

Testing is more complex. Code that writes to IndexedDB requires a DOM-capable test environment. Vitest with jsdom handles this, but setup is more involved than mocking a fetch call. The repo abstraction helps, unit tests inject a mock repo, integration tests hit a real in-memory IndexedDB instance.

Migration is manual. Schema migrations in Dexie are code, not SQL. Version 9 has 9 sequential migration steps. Manageable today, but it demands discipline as the schema evolves.


Why This Pattern Scales

The three-mode architecture earns its complexity when you see what it enables for free:

  • Full offline support: Already there. No extra code needed.
  • Export/import: Read from Dexie, serialize to JSON/CSV. Works offline.
  • Multi-plan scenarios: Each plan is an isolated namespace in IndexedDB. Switching plans is instant.
  • Multi-currency: Exchange rates cached locally. Currency conversion works offline with last-known rates.
  • Encryption: Wraps the existing Supabase layer. The Dexie layer stays untouched.

Every feature that reads or writes data gets offline support automatically because IndexedDB is always the first stop. The sync engine handles getting changes to the cloud eventually. Features ship faster because the hard problem, "where does the data live?", is already solved.


Start Here

If you are building a web app where data privacy matters, the local-first model is worth the investment. The core pattern is five layers deep, but each layer is straightforward on its own:

  1. IndexedDB (via Dexie) as the source of truth — not the server.
  2. TanStack Query as a cache — with query functions that read from IndexedDB, not fetch from APIs.
  3. A repo interface — implemented by both a local Dexie repo and a remote sync repo.
  4. A routing function — that selects the right repo based on user preference.
  5. A sync engine — that runs in the background, never in the critical path.

The user gets privacy by default and cloud sync as a deliberate choice. That is a better deal than most apps offer. And it is achievable with standard web platform APIs — no proprietary infrastructure, no vendor lock-in, no trust required.

Your data, on your device, on your terms. That should not be a radical idea. But until more apps are built this way, it still is.

Visit Talliofi Website

Top comments (0)