02 — CHAT ANALYZER / 2025

A MILLION MESSAGES,
ZERO SERVERS

A Vue 3 analytics platform that ingests a raw Telegram JSON export and renders virtualized tables and charts over up to 1,000,000 messages entirely in the browser — no upload, no backend, no data leaving the device.

ROLE
Solo Architect & Developer
YEAR
2025
STACK
Vue 3 + TypeScript + SQLite (WASM + OPFS)
SCALE
1M rows (SQLite) · 200k on IndexedDB fallback
01 — CONTEXT & PROBLEM

A DATABASE PROBLEM DISGUISED AS A UI PROBLEM

A Telegram chat export can contain hundreds of thousands to over a million messages in a single JSON file, sometimes hundreds of megabytes on disk. Loading, storing, filtering, and rendering that volume in a browser tab — without freezing the UI thread or exhausting memory — is a genuinely hard client-side engineering problem.

Most “analytics dashboards” quietly assume a server and a real database doing the heavy lifting. This one had neither, by design — the privacy promise only holds if nothing ever leaves the browser.

The database, the query planner, and the render pipeline all had to be built from scratch — inside a browser tab.
02 — ARCHITECTURE

CLEAN ARCHITECTURE, ENFORCED BY THE COMPILER

┌─────────────────────────────────────────────┐
│         Presentation (Vue Components)        │
│   FilterBar · MessageTableLazy · Charts      │
└──────────────────────┬────────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│     Application (Strategies · Use Cases)     │
│   Cursor-based QueryStrategy: SQLite         │
│   primary, IndexedDB adapter as fallback     │
└──────────────────────┬────────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│        Domain (Entities · Interfaces)        │
│   ChatMessage · ChatRepository contract      │
└──────────────────────┬────────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│              Infrastructure                  │
│  SqliteChatRepository (WASM + OPFS) primary  │
│  IndexedDBChatRepository (Dexie), unmodified,│
│    kept as the automatic fallback            │
│  4× Web Workers: SQLite / JSON parse /       │
│    query / analytics                         │
│  VirtualTableDataProxy — storage-agnostic,   │
│    LRU-cached, cursor-chaining               │
└─────────────────────────────────────────────┘
     SQLite path: 1M rows verified. Fallback: 200k

Domain, application, infrastructure, and presentation are separated explicitly — ChatRepository and QueryStrategy are interfaces the domain layer depends on; SQLite (via a dedicated worker) and the original, unmodified Dexie/IndexedDB code are two implementations the infrastructure layer provides.

That separation is what makes it safe to run SQLite as the primary path and fall back to IndexedDB — when the environment can't provide the cross-origin isolation OPFS needs, or a second tab already holds the SQLite lock — without the UI layer knowing or caring which backend answered the query.

Four dedicated Web Workers — SQLite, JSON parsing, query, analytics — keep the render thread free regardless of dataset size or which backend is active.
03 — IMPLEMENTATION

THREE KEY DECISIONS

A — VIRTUALIZED, PROXY-BACKED TABLE

An array-like interface over a database the DOM never sees

TanStack Virtual expects an array. A custom VirtualTableDataProxy presents exactly that interface while transparently paging, LRU-caching, and chaining cursors between pages underneath — so the DOM never holds more than the visible window, and the proxy itself never needs to know whether SQLite or IndexedDB is answering the query.

infrastructure/virtualTableProxy.ts
// virtualTableProxy.ts
class VirtualTableDataProxy {
  private cache = new LRUCache<number, Row[]>(maxCachedPages)
  private cursors: Cursor[] = [] // chained, one per fetched page

  async getPage(i: number): Promise<Row[]> {
    if (this.cache.has(i)) return this.cache.get(i)!
    const cursor = this.cursors[i - 1]
      ?? await this.strategy.resolveRank(this.filter, i * PAGE_SIZE) // cold jump
    const { items, nextCursor } = await this.strategy.fetchPage({
      filter: this.filter, cursor, limit: PAGE_SIZE,
    })
    this.cursors[i] = nextCursor
    return this.cache.set(i, items)
  }
}
// same proxy drives either backend — it never
// knows whether `strategy` is SQLite- or IndexedDB-backed
B — CURSOR-BASED QUERY STRATEGY, SQLITE PRIMARY

One interface, two engines — a real query planner where it matters

QueryStrategy asks for “the next page after this cursor,” never an offset. SqliteQueryStrategy is the primary implementation, running against a real cost-based planner; LegacyIndexedDbQueryStrategy adapts the original, unmodified IndexedDB code to the same interface as the fallback path.

IndexedDB isn't a slower version of the same trick — it has no query planner at all, which is why it was replaced as the primary path rather than tuned further.

application/strategies/QueryStrategies.ts
// QueryStrategies.ts
interface QueryStrategy {
  fetchPage(p: { filter: MessageFilter; cursor?: Cursor; limit: number }):
    Promise<{ items: ChatMessage[]; nextCursor?: Cursor }>
  count(filter: MessageFilter): Promise<number>
  resolveRank(filter: MessageFilter, rank: number):
    Promise<{ cursorBefore: Cursor; exact: boolean }>
}

// primary — thin wrapper over the SQLite Web Worker
class SqliteQueryStrategy implements QueryStrategy { /* ... */ }

// fallback — adapts the old (unmodified) IndexedDB
// seed-map machinery to the same cursor-based interface
class LegacyIndexedDbQueryStrategy implements QueryStrategy { /* ... */ }
C — TEN JUSTIFIED INDEXES, NEVER `OFFSET`

A real planner retires the hand-rolled aggregate tables

The old IndexedDB analytics path kept separate materialized-view tables — daily, hourly, per-sender counts — hand-maintained on every write, because Dexie couldn't aggregate on read. SQLite's planner makes that unnecessary: the Chart.js dashboard now answers straight from a SQL GROUP BY over derived columns computed once at write time.

Every one of the 10 indexes is justified against a real query the UI can issue — no speculative indexes — and every multi-value filter is built as a bounded, per-value subquery merged with UNION ALL, since OFFSET on a multi-type filter forces a full temp-table sort regardless of indexing.

A chunked, resumable one-time migration copies existing IndexedDB data into SQLite before the app mounts, reporting progress via a typed worker message protocol — the original data is never deleted, just no longer read once SQLite has taken over.
04 — TRADEOFFS

DECISIONS & THEIR COSTS

SQLite compiled to WASM (OPFS) over the original IndexedDB-only design

An audit that loaded 1,000,000 rows and exercised every filter × sort combination the UI exposes found IndexedDB has no query planner — a multi-type filter or non-timestamp sort degrades into a full table scan regardless of how few rows actually match, getting slower as the chat grows, not as the filter narrows.

The original scope was capped at 200,000 messages — the largest size directly verified consistent — rather than ship a table that silently got slower. That cap still applies to the (unmodified) IndexedDB fallback.

Exclusive Web Lock per tab, not shared reads across tabs

OPFS's SyncAccessHandle Pool VFS allows only one open handle at a time — a second tab racing to open the same database file would corrupt it.

A second tab detects the lock immediately and falls back to the IndexedDB path rather than sharing live SQLite state with the first — correct, but not unified across tabs.

Per-branch keyset `UNION ALL` over `OFFSET` pagination

A multi-type filter + timestamp sort with `OFFSET` forces SQLite to materialize and sort every row up to that offset — measured 18.7s at 1,000,000 rows.

Each filter value needs its own bounded, indexed subquery merged with `UNION ALL` — more query-builder complexity, in exchange for a measured 15ms on the same query.

Streaming JSON parser over `JSON.parse`

Telegram exports run into the hundreds of megabytes. Materializing the full parse tree before storing anything spikes worker memory hard.

Ingestion is slower to reach "first row visible" than a naive parse, in exchange for a memory ceiling that does not depend on file size.

05 — OUTCOMES

MEASURED, NOT ASSUMED

~200MB
Peak parser memory

Down from an estimated 600–800MB with a naive JSON.parse on the same export, per in-code measurement notes

15ms
Deep-page keyset query

Down from 18.7s using OFFSET — a 3-type filter + timestamp sort at 1,000,000 rows, measured against a real SQLite engine

1,000,000
Rows, every filter × sort

Verified under 500ms — including the multi-type-filter and non-timestamp-sort cases that broke the original IndexedDB design well before that scale

The parser memory figure is documented in-code from profiling during development. The keyset and 1,000,000-row numbers are independently measured against a real SQLite engine (33 unit cases) and a real Chromium browser via Playwright — not estimated.

06 — REFLECTION

PATTERNS EARNED THEIR PLACE, NOT ASSUMED IT

Strategy, Proxy, and Repository are textbook GoF patterns — but here each one exists because a concrete memory or latency constraint demanded it, not because a Clean Architecture diagram called for it. The LRU cache, the keyset pagination, the chunked migration: every one solves a measured problem at the row counts this tool actually has to handle.

A dev-mode cache-hit-rate badge sits in the corner of the table for a reason: instrumenting the result beats assuming the architecture works.

07 — WHY THIS MATTERS

FOR FREELANCE / PROJECT CLIENTS

If your tool needs to handle real user-scale data without a server bill, this is the pattern — SQLite compiled to WASM, keyset pagination, and worker-backed queries that stay fast past a million rows, with an honest fallback for the environment that can't support it.

FOR FULL-TIME / HIRING TEAMS

Every pattern here (Strategy, Proxy, Repository) exists because a measured constraint demanded it, not because a diagram called for it — including the decision to replace IndexedDB after an audit, not a hunch, found its ceiling.

NEXT STEP

LIKE WHAT YOU SEE? LET'S TALK ABOUT YOURS.