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 100k–1M+ messages entirely in the browser — no upload, no backend, no data leaving the device.

ROLE
Solo Architect & Developer
YEAR
2025
STACK
Vue 3 + TypeScript + IndexedDB
SCALE
100k–1M+ messages, client-only
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)     │
│   HybridQueryStrategy routes each query      │
└──────────────────────┬────────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│        Domain (Entities · Interfaces)        │
│   ChatMessage · ChatRepository contract      │
└──────────────────────┬────────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│              Infrastructure                  │
│  IndexedDBChatRepository (Dexie)             │
│  4× Web Workers: parse / query / sort / stat │
│  VirtualTableDataProxy (LRU + keyset paging) │
└─────────────────────────────────────────────┘
              all state: IndexedDB, in-browser

Domain, application, infrastructure, and presentation are separated explicitly — ChatRepository and QueryStrategy are interfaces the domain layer depends on; Dexie and the worker bridge are implementations the infrastructure layer provides.

That separation is what makes it safe to route some queries straight to IndexedDB and others through a Web Worker without the UI layer knowing or caring which path ran.

Four dedicated Web Workers — parsing, querying, sorting, analytics — keep the render thread free regardless of dataset size.
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 prefetching rows underneath — so the DOM never holds more than the visible window, no matter how large the table is.

infrastructure/virtualTableProxy.ts
// virtualTableProxy.ts
class VirtualTableDataProxy {
  private cache = new LRUCache<number, Row>(WINDOW_SIZE * 3)
  private seedMap: number[] | null = null // sorted primary keys

  async get(index: number): Promise<Row> {
    if (this.cache.has(index)) return this.cache.get(index)!
    const key = this.seedMap
      ? this.seedMap[index]        // O(1) — keyset already built
      : await this.offsetLookup(index)
    const row = await this.repo.getByKey(key)
    this.prefetch(index)           // fire-and-forget window fill
    return this.cache.set(index, row)
  }
}
B — STRATEGY-ROUTED QUERY ENGINE

Cheap reads stay fast, expensive reads stay off-thread

A HybridQueryStrategy inspects every query and routes text search, non-indexed sort, and large-limit reads to a Web Worker, while cheap indexed lookups stay on the main thread for lower latency.

Neither path is a fallback for the other — each is the right tool for its own query shape.

application/strategies/QueryStrategies.ts
// QueryStrategies.ts
class HybridQueryStrategy implements QueryStrategy {
  async execute(filter: MessageFilter) {
    const needsWorker =
      filter.searchText ||
      filter.sort !== 'timestamp' ||
      filter.limit > DIRECT_READ_THRESHOLD

    return needsWorker
      ? this.workerBridge.run(filter)   // off main thread
      : this.indexedDb.readDirect(filter) // cheap indexed read
  }
}
C — MATERIALIZED AGGREGATES

Analytics that don't re-scan a million rows per chart

Daily, hourly, and per-sender count tables are kept in sync on every write, so the Chart.js dashboard — top senders, daily volume, weekly heatmap, time-of-day — reads from O(days) worth of rows instead of O(total messages).

A chunked, resumable v3 schema migration re-derives these views for 1M+ existing rows in 5,000-row batches, designed to survive interruption without corrupting state.

The filter bar, table, and dashboard all read the same URL-synced filter state — every view is shareable via a link, not just the table.
04 — TRADEOFFS

DECISIONS & THEIR COSTS

IndexedDB (Dexie) over a real backend DB

The whole premise is client-only — no server, no data leaving the browser. A backend would solve the storage problem trivially but break the privacy guarantee.

Every index, migration, and aggregate view has to be hand-built and versioned — there is no query planner to lean on.

Web Worker offload, routed by a Strategy, not blanket

Cheap indexed reads on the main thread are faster than a worker round-trip. Only text search, non-indexed sort, and large-limit queries justify leaving the thread.

Two code paths to maintain and keep behaviourally identical, verified by the same Playwright suite regardless of which path ran.

Keyset ("seed map") pagination over offset scans

Jumping to row 500,000 in a naive offset query is an O(n) collection scan. A background-built sorted key array turns it into a point lookup.

The seed map itself must be rebuilt whenever filters change, so it is built incrementally and cached, not recomputed per keystroke.

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

694
Lines of Playwright E2E

Across 3 specs — virtual scroll, filtering, and sort correctness against seeded datasets

O(log n)
Deep-page lookup

Per-page keyset lookups replace an O(n) offset scan for jumping deep into a 1M-row table

Memory figures are documented in-code from profiling during development, not an independently re-run benchmark — reported as designed/measured behaviour.

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 — keyset pagination and worker-backed queries that stay fast past a million rows.

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 — the instrumentation is in the product, not just the pitch.

NEXT STEP

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