Back to Blog

How We Achieve Sub-50ms Interactions Using SQLite on the Client

The Benchmark That Changed Our Thinking

Early in FlowEra’s development we ran a simple test: open a popular task management tool, drag a card from one column to another, and time the round trip. On a fast connection: 120–180ms. On a 3G connection: 800ms–1.5s. On a train with intermittent connectivity: a spinner, then an error.

That’s the baseline the industry accepts. We didn’t.

The Core Idea

If the data is already on your device, every interaction can be instant — not because we’re clever with HTTP optimization, but because the network is not involved at all.

FlowEra runs a full SQLite database inside your browser (via WebAssembly) that contains a synchronized copy of all the workspace data you have access to. When you drag a card, we write to that local database. The UI reads from that local database. The server never touches this interaction path.

The Sync Layer: PowerSync

We use PowerSync as the bidirectional sync engine between the client SQLite database and our PostgreSQL backend. PowerSync handles:

  • Initial replication: when you open a workspace, PowerSync downloads the relevant subset of your data into local SQLite via a streaming sync protocol.
  • Incremental updates: changes from other users arrive via a WebSocket-backed sync stream and are applied to your local database as they happen.
  • Write-back: when you make a change locally, PowerSync queues it and sends it to our POST /api/data write-back endpoint. If you’re offline, the queue persists and flushes when connectivity returns.

This means the write path is: user action → local SQLite write → UI update. The network path is asynchronous and happens in parallel.

Why SQLite Specifically

We evaluated IndexedDB, in-memory stores, and SQLite-on-WASM before committing to SQLite. The choice came down to query expressiveness. Our most complex views — Gantt with dependency chains, cumulative flow diagrams, lead time distributions — require multi-table JOINs with aggregations. IndexedDB can’t do that. An in-memory store requires us to implement query logic in JavaScript, which is expensive to maintain.

SQLite-on-WASM gives us a full relational query engine running locally. We write the same SQL that runs on the server and it executes in-browser in microseconds.

Conflict Resolution

With multiple clients writing simultaneously, conflicts are inevitable. PowerSync uses a last-write-wins strategy per field, with server timestamps as the authority. Field-level granularity means most concurrent edits don’t produce conflicts at all — if you update the task title while a teammate updates the status, both changes survive independently.

For cases that do conflict — two people update the same field simultaneously — the server’s recorded timestamp determines which value wins. This isn’t perfect for all use cases (collaborative text editing is a different problem), but for structured task data it produces correct results in nearly every real-world scenario.

The Numbers

On a modern laptop over WiFi:

  • Task status change: ~8ms (local SQLite write + React re-render)
  • Drag-and-drop reorder: ~12ms
  • Opening a flow with 500 tasks: ~40ms (local SQLite query)
  • Sync propagation to another client: ~150ms (network dependent)

The user never waits for the server. The server catches up asynchronously.

Trade-offs We Made

Local-first isn’t free. The initial sync takes time when you first open a workspace. The client-side bundle is larger because it includes the SQLite WASM binary. And debugging sync issues requires understanding the two-database architecture.

We made these trade-offs deliberately. For a tool that you use dozens of times a day, interaction latency is the thing you feel on every action. Initial load happens once per session.

See it in action