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/datawrite-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.