SQLite WASM vs GenosDB: Real Benchmarks on a 200k-Relation Dataset
Dropping sqlocal, the COOP/COEP headers and 12 MB of downloads from a real app — with byte-identical query results, 5 to 100 times faster.

Full Stack Developer - dWEB R&D
Most people meet GenosDB through its P2P side: real-time sync, WebRTC rooms, decentralized apps without a backend. This post is about the other side, the one almost nobody asks about — GenosDB as a plain local-first store, no P2P involved. And instead of a synthetic benchmark, the numbers come from a real app, with a real dataset, against a real incumbent: SQLite compiled to WASM.
TL;DR — on a dataset of 9,783 geo-tagged stops, 4,359 routes and 208,229 relations, GenosDB returned byte-identical results on every query while being 5–100× faster, cut first-visit data transfer 25×, removed over a megabyte of WASM engine and worker code from the bundle, and eliminated the COOP/COEP header requirement entirely.
The app
The app is a community bus tracker for a large city, built on a public GTFS feed. Riders inside a bus become live GPS "feeders" over GenosDB's WebRTC data channels; everyone else watches buses move on a Leaflet map. It is exactly the kind of serverless, community-run product GenosDB was designed for, and it already used GenosDB for the networking side.
But it carried a second stack alongside. The static GTFS index — every stop, every route, and which routes serve which stops — lived in SQLite via sqlocal, shipped as a prebuilt 12.5 MB binary that every new visitor downloaded and copied into OPFS.
Which raised an obvious question: if GenosDB is already in the app for networking, can it also be the local database? We built a working branch to find out. The app itself is not mine to name, so what follows is the engineering, not the case study: the dataset is a public GTFS feed and every number below can be reproduced against one.
The swap
The refactor touched exactly two files of app logic (db/client.ts, db/queries.ts) plus the build script. No UI component changed. The four query functions kept their exact signatures.
The storage instance is local-only — note what's not in the options:
const { gdb } = await import('genosdb')
const db = await gdb('gtfs-index') // no rtc — storage only
No rtc means no signaling, no peers, no sync: just a graph store persisted to OPFS through GenosDB's worker, with spatial queries available out of the box. The P2P instance (rtc: true) lives separately in the connection layer — static reference data has no business syncing between peers.
Instead of a 12.5 MB SQLite binary, the app now ships a 2.2 MB gtfs.json (~500 KB gzipped) and seeds the store on first visit:
await db.put({
type: 'stop',
sid: id,
name,
location: { latitude, longitude }, // indexed for spatial queries
routeIds // the join table, denormalized
}, `stop-${id}`)
And the app's hottest query — "which stops are near the user?" — became a geo query:
const { results } = await db.map({
query: {
type: 'stop', // top-level fields AND together
location: { $near: { latitude, longitude, radius: 1 } } // radius in km
}
})
Choosing the model: measure, don't guess
The GTFS join table (208k stop↔route pairs) could be modeled two ways: as graph links (db.link(), one edge per pair) or as denormalized id arrays on the nodes. We benchmarked both at full scale before committing:
| links (208k edges) | arrays | |
|---|---|---|
| Seed time | ~4.2 s | ~300 ms |
| Warm open | 142 ms | 136 ms |
| Lookup queries | 0–11 ms | 0–0.2 ms |
Links work fine — 52k links/s sustained is nothing to apologize for — but for this access pattern (fetch a known node, follow its relations) arrays win on every axis, so arrays it is. The graph-y model would have been the showcase choice; the arrays are the engineering choice. Ship the engineering choice.
The numbers
Everything below was measured on the real dataset — 9,783 stops, 4,359 routes, 208,229 relations — on an M-series MacBook running Chromium, with the same inputs against both engines and results sorted and diffed. All four queries returned byte-identical results.
Query latency:
| query | sqlocal (SQLite WASM) | GenosDB | speedup |
|---|---|---|---|
| closest stops (1 km radius) | 29.7 ms | 6.1 ms | ~5× |
| routes for a stop (143 rows) | 15.0 ms | 0.2 ms | ~75× |
| stops for a route (73 rows) | 11.4 ms | 0.1 ms | ~100× |
| route search | 2.7 ms | 0.1 ms | ~27× |

Ingestion and startup:
| phase | time |
|---|---|
| put 9,783 geo-tagged stops | 171 ms (~57k ops/s) |
| put 4,359 routes | 86 ms |
| total first-visit seed | ~300 ms |
| warm open, return visits (14k nodes from OPFS) | ~140 ms |
Footprint:
| before | after | |
|---|---|---|
| First-visit data download | 12.5 MB | ~500 KB gzipped |
| WASM in the bundle | 860 KB engine + 209 KB worker | none |
dist/ total |
19 MB | 8.3 MB |
| COOP/COEP headers | required | not needed |

That last row deserves a sentence. SQLite WASM needs SharedArrayBuffer, which needs cross-origin isolation headers on every deploy — the app's own docs flagged them as "breaking change if missing." GenosDB persists through OPFS from a worker without any of that. Deleting netlify.toml from the repo was the single most satisfying part of the diff.
Not just microbenchmarks
Query parity is necessary but not sufficient, so we also ran the entire product on the branch: mocked GPS in a dense downtown area, two separate browser origins — one as a live GPS feeder, one as a watcher. Stop discovery, route search, joining the P2P room, feeder broadcast, cross-origin feeder discovery ("1 bus ~75m" in the route list), and the blue bus marker moving on the watcher's map over real WebRTC with Nostr signaling. The watcher origin seeded its store from scratch along the way.
We also served the production build (not just the dev server) and re-ran the flow — worth calling out because bundling a library that resolves its own modules dynamically is exactly where builds tend to break. Vite inlines GenosDB's geo module into the main chunk, and every geo query works identically in the bundled app: same 29 stops, clean console.
Data model, methodology and verification steps were documented alongside the branch, and every measurement above came from the same machine, the same dataset and the same browser, with both engines' outputs sorted and diffed.
When should you reach for this?
The pattern generalizes to any app with a chunky, mostly-static reference dataset: product catalogs, map data, dictionaries, game content. The recipe:
- Ship the dataset as gzipped JSON (a build script, not a database binary)
gdb('my-store')— addrtconly if the data should sync- Seed once behind your loading screen; version it with a meta node
- Query with
$nearfor spatial data,db.getfor known ids,db.mapfor everything else
One library, one mental model — and if some of your data should be shared later, the P2P layer is one option away.
The production builds of GenosDB are free for personal and commercial use; the source is proprietary by deliberate governance choice. Install it with npm i genosdb — github.com/estebanrfp/gdb.
⭐ Found this useful? Star GenosDB on GitHub — or spin it up in seconds: npm i genosdb.
This article is part of the official documentation of GenosDB (GDB). GenosDB is a distributed, modular, peer-to-peer graph database built with a Zero-Trust Security Model, created by Esteban Fuster Pozzi (estebanrfp).
📄 Whitepaper | overview of GenosDB design and architecture
🛠 Roadmap | planned features and future updates
💡 Examples | code snippets and usage demos
📖 Documentation | full reference guide
🔍 API Reference | detailed API methods
💬 GitHub Discussions | community questions and feedback
🗂 Repository | Minified production-ready files
📦 Install via npm | quick setup instructions




