{"schemaVersion":1,"id":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases","title":"SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer","description":"A practical guide to running SQLite as a distributed edge database. LiteFS replication internals, libSQL extensions, Turso's architecture, and real benchmarks showing when SQLite obliterates Postgres for read-heavy edge workloads.","language":"en","datePublished":"2025-10-30","dateModified":null,"contentFormat":"text/markdown","content":"Databases\n\n# SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer\n\n2025-10-309 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/sqlite-litefs-distributed-edge-databases>)\n\n## Why SQLite at the Edge Makes Sense\n\nThe fundamental insight is this: **if your database is a file on the same machine as your application, reads are a function call, not a network round trip.** The latency difference is extraordinary:\n\n```\nPostgres (same region): ~2ms per query\nPostgres (cross-region): ~50-180ms per query\nSQLite (local file, WAL mode): ~0.02ms per query\n```\n\nThat's not a typo. SQLite reads in WAL mode are literally 100x faster than same-region Postgres because there's no TCP handshake, no protocol negotiation, no query parsing over the wire, no result serialization. It's a B-tree lookup on a memory-mapped file.\n\nThe catch, obviously, is that SQLite is a single-node database. It doesn't replicate. It doesn't cluster. It writes to a single file.\n\nThat's where LiteFS, Turso, and libSQL come in.\n\n## LiteFS: Transparent Replication for SQLite\n\n[LiteFS](<https://fly.io/docs/litefs/>), built by the [Fly.io](<https://fly.io/>) team, is genuinely clever. It's a FUSE filesystem that sits between your application and the actual SQLite file, intercepting all write operations.\n\nHere's how the replication works at a low level:\n\n```\nApplication writes to SQLite\n ↓\nFUSE intercepts the write syscall\n ↓\nLiteFS captures changed WAL pages\n ↓\nPages are shipped to primary node via HTTP\n ↓\nPrimary applies pages and forwards to replicas\n ↓\nReplicas apply pages to their local SQLite file\n```\n\nThe key design decision is that **LiteFS replicates at the page level, not the SQL level.** It doesn't replay SQL statements, it ships the actual modified 4KB database pages. This means:\n\n1. Replication is SQL-agnostic (works with any SQLite extension, custom functions, etc.)\n2. The overhead is proportional to data changed, not query complexity\n3. There's no need to parse or validate SQL on the replica side\n\n### Setting Up LiteFS on Fly.io\n\n```yaml\n# fly.toml\n[mounts]\n source = \"litefs\"\n destination = \"/var/lib/litefs\"\n\n[processes]\n app = \"/usr/local/bin/litefs mount\"\n\n# litefs.yml\nfuse:\n dir: \"/litefs\"\n\ndata:\n dir: \"/var/lib/litefs\"\n\nproxy:\n addr: \":8080\"\n target: \"localhost:3000\"\n db: \"myapp.db\"\n passthrough:\n - \"*.ico\"\n - \"*.png\"\n\nexec:\n - cmd: \"node server.js\"\n\nlease:\n type: \"consul\"\n advertise-url: \"http://${FLY_ALLOC_ID}.vm.${FLY_APP_NAME}.internal:20202\"\n consul:\n url: \"${FLY_CONSUL_URL}\"\n key: \"litefs/${FLY_APP_NAME}\"\n```\n\nThe ` proxy ` section is important, LiteFS runs a reverse proxy that automatically forwards write requests to the primary node. Your application doesn't need to know about the replication topology.\n\n### Write Forwarding\n\nWhen your app running on a replica in Tokyo receives a POST request that needs to write to the database, here's what happens:\n\n```\n1. Request hits LiteFS proxy on Tokyo replica\n2. LiteFS detects it's a write (POST/PUT/DELETE or SQL INSERT/UPDATE/DELETE)\n3. LiteFS forwards the request to the primary (Virginia) via Fly-Replay header\n4. Primary executes the write\n5. Changed pages replicate to Tokyo (~120ms later)\n6. Response returns to client\n```\n\nThe write latency is the network round trip to the primary plus replication time. For a user in Tokyo writing to a Virginia primary, that's roughly 180-250ms. Reads, however, are still hitting the local replica: 0.02ms.\n\nFor a content site where 98% of requests are reads, this trade-off is insanely good.\n\n## Turso and libSQL: SQLite with Server Capabilities\n\n[Turso](<https://turso.tech/>) takes a different approach. Instead of transparent filesystem-level replication, they forked SQLite into libSQL and added server-level features:\n\n```\nlibSQL additions over SQLite:\n├── Server mode (sqld), listens on HTTP/WebSocket\n├── Embedded replicas, local SQLite file that syncs with remote\n├── Namespaces, multi-tenant database isolation\n├── Vector search, native vector embeddings (VECTOR type)\n├── ALTER TABLE improvements, column type changes\n└── WASM-based user-defined functions\n```\n\nThe embedded replica feature is what makes Turso interesting for edge deployments:\n\n```javascript\nimport { createClient } from \"@libsql/client\";\n\nconst client = createClient({\n url: \"file:local-replica.db\", // local SQLite file\n syncUrl: \"libsql://mydb-myorg.turso.io\", // remote primary\n authToken: process.env.TURSO_AUTH_TOKEN,\n syncInterval: 60, // sync every 60 seconds\n});\n\n// Reads hit the local file, ~0.1ms\nconst users = await client.execute(\"SELECT * FROM users WHERE active = 1\");\n\n// Writes go to the remote primary, ~50ms\nawait client.execute({\n sql: \"INSERT INTO users (name, email) VALUES (?, ?)\",\n args: [\"Alice\", \"alice@example.com\"],\n});\n\n// Force sync when you need fresh data\nawait client.sync();\n```\n\nThe sync mechanism is more sophisticated than LiteFS. Turso tracks a vector clock per namespace and only ships the delta of changed frames since the last sync. The embedded replica maintains a local WAL that gets reconciled during sync.\n\n## Real Benchmarks\n\nI ran these against a real-world dataset: 2 million product records, ~800MB database, running on Fly.io machines (shared-cpu-1x, 256MB RAM).\n\n### Read Performance\n\n```\nQuery: SELECT * FROM products WHERE category = ? AND price < ? ORDER BY rating DESC LIMIT 20\nIndex: CREATE INDEX idx_products_cat_price ON products(category, price)\n\nSQLite (local, WAL mode):\n p50: 0.08ms\n p99: 0.3ms\n Throughput: 42,000 queries/sec (single connection)\n\nTurso (embedded replica, synced):\n p50: 0.09ms\n p99: 0.35ms\n Throughput: 38,000 queries/sec\n\nPostgres (same region, pgbouncer):\n p50: 1.8ms\n p99: 4.2ms\n Throughput: 8,500 queries/sec (50 connections)\n\nPostgres (cross-region, nearest replica):\n p50: 12ms\n p99: 45ms\n Throughput: 2,100 queries/sec\n```\n\nSQLite is 5x faster than same-region Postgres and 150x faster than cross-region. The throughput numbers are even more dramatic because SQLite has no connection overhead, every query is a direct function call into the B-tree.\n\n### Write Performance\n\n```\nQuery: INSERT INTO orders (user_id, product_id, quantity, total) VALUES (?, ?, ?, ?)\n\nSQLite (local, WAL mode):\n p50: 0.05ms\n p99: 2.1ms (WAL checkpoint spikes)\n Throughput: 18,000 inserts/sec (single writer)\n\nLiteFS (write to primary, replicate):\n p50: 85ms (network to primary)\n p99: 210ms\n Throughput: ~200 writes/sec (limited by network RTT)\n\nTurso (embedded replica, write to primary):\n p50: 52ms\n p99: 130ms\n Throughput: ~350 writes/sec\n\nPostgres (same region):\n p50: 1.5ms\n p99: 5ms\n Throughput: 12,000 inserts/sec (parallel writers)\n```\n\nHere's where Postgres wins decisively. SQLite's single-writer constraint means it can't parallelize writes. And in an edge deployment, every write has to hit the primary over the network. If you're doing more than a few hundred writes per second, you need Postgres (or another multi-writer database).\n\n## When to Actually Use This\n\nAfter running SQLite at the edge in production for over a year, here's my decision framework:\n\n**Use SQLite + LiteFS/Turso when:**\n\n- Read-to-write ratio is 90%+ reads\n- Dataset fits comfortably on a single machine (\\<100GB)\n- You need sub-millisecond read latency globally\n- You want zero database operational overhead\n- You're on a platform that supports it (Fly.io, Cloudflare Workers with D1, etc.)\n\n**Stick with Postgres when:**\n\n- Write throughput matters (\\>500 writes/sec sustained)\n- You need complex transactions across multiple tables\n- Dataset is large (\\>100GB) or growing fast\n- You need advanced features (JSONB, full-text with custom dictionaries, materialized views)\n- You have a dedicated DBA team\n\n**The hybrid approach** (what I actually do for most projects):\n\n```\n┌─────────────────────────────┐\n│ Edge (Fly.io regions) │\n│ ┌───────────────────────┐ │\n│ │ SQLite (via LiteFS) │ │\n│ │ - User sessions │ │\n│ │ - Content cache │ │\n│ │ - Feature flags │ │\n│ │ - Read-heavy queries │ │\n│ └───────────────────────┘ │\n└─────────────┬───────────────┘\n │ writes + complex queries\n ▼\n┌─────────────────────────────┐\n│ Central (single region) │\n│ ┌───────────────────────┐ │\n│ │ Postgres │ │\n│ │ - Transactional data │ │\n│ │ - Analytics │ │\n│ │ - Full-text search │ │\n│ │ - Write-heavy tables │ │\n│ └───────────────────────┘ │\n└─────────────────────────────┘\n```\n\nSQLite at the edge for the hot, read-heavy path. Postgres in a central region for the transactional, write-heavy stuff. The edge SQLite databases sync from Postgres via a CDC pipeline or periodic ETL.\n\n## Advanced: Custom libSQL Extensions\n\nOne thing that pushed me toward Turso was libSQL's WASM-based user-defined functions. You can write custom functions in any language that compiles to WASM and load them into SQLite:\n\n```rust\n// distance.rs, compiled to WASM\nuse libsql_bindgen::*;\n\n#[libsql_bindgen]\npub fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {\n let r = 6371.0; // Earth's radius in km\n let dlat = (lat2 - lat1).to_radians();\n let dlon = (lon2 - lon1).to_radians();\n let a = (dlat / 2.0).sin().powi(2)\n + lat1.to_radians().cos() * lat2.to_radians().cos()\n * (dlon / 2.0).sin().powi(2);\n let c = 2.0 * a.sqrt().asin();\n r * c\n}\n```\n\nThen use it in SQL:\n\n```sql\nSELECT name, haversine(lat, lon, -34.6037, -58.3816) AS distance_km\nFROM stores\nWHERE distance_km < 10\nORDER BY distance_km;\n```\n\nThis runs at native speed inside the SQLite query engine. No external service calls, no UDF overhead from interpreted languages. For geo queries on edge databases, this is a game-changer.\n\n## Operational Reality\n\nLet me be honest about the rough edges. SQLite at the edge isn't all sunshine:\n\n1. **Schema migrations are tricky.** You can't run ` ALTER TABLE ... ADD COLUMN ` with a default value on a large table without locking it. libSQL improves this, but vanilla SQLite's ALTER TABLE is famously limited.\n2. **Monitoring is immature.** There's no ` pg_stat_statements ` equivalent. You have to instrument your application code or use SQLite's ` sqlite3_trace_v2 ` callback.\n3. **Backup requires thought.** You can't just ` pg_dump `. LiteFS handles continuous backup to S3, but point-in-time recovery requires keeping WAL files.\n4. **Connection pooling doesn't exist** (because there are no connections). This is actually a feature, no more PgBouncer configuration nightmares, but it means your application must handle SQLite's busy timeout correctly.\n\nDespite these issues, I'm genuinely convinced that SQLite at the edge is the right architecture for a large class of applications that are currently over-provisioning with Postgres or MySQL. The performance characteristics are too good to ignore, and the tooling has finally matured to the point where it's production-ready.\n\nThe database world is having a \"right tool for the right job\" moment. Postgres is exceptional, but not everything needs a client-server database. Sometimes the best database is the one embedded in your application, replicated to every edge node, serving reads in microseconds.\n\n## Frequently asked questions\n\n### Can SQLite handle concurrent writes in production?\n\nSQLite supports one writer at a time, even in WAL mode. For read-heavy workloads (95%+ reads), this is rarely a bottleneck. For write-heavy workloads, you need to serialize writes through a single process or use Turso/libSQL which implements server-side write coordination.\n\n### How does LiteFS replication work?\n\nLiteFS uses a FUSE filesystem that intercepts SQLite's WAL writes. When a transaction commits, LiteFS captures the changed pages and ships them to replica nodes over HTTP. Replicas apply the pages to their local copy. Replication lag is typically 50-200ms depending on network distance.\n\n### When should I use SQLite over Postgres at the edge?\n\nUse SQLite when your workload is read-heavy (\\>90% reads), your data fits on a single node (under ~100GB), you need sub-millisecond read latency, and you want zero operational overhead. Use Postgres when you need complex transactions, full-text search with custom dictionaries, or write-heavy workloads.\n\n## Tips\n\n1. **WAL mode is non-negotiable:** Always run ` PRAGMA journal_mode=WAL; ` before anything else. WAL (Write-Ahead Logging) allows concurrent readers during writes. Without it, a single write locks the entire database. LiteFS requires WAL mode, it won't replicate correctly with rollback journals.\n2. **Turso embedded replicas latency trick:** Use ` @libsql/client ` with ` syncUrl ` and ` syncInterval ` to create a local SQLite replica that syncs with Turso's edge network. Reads hit the local file (~0.1ms), writes go to the primary (~50ms). Set ` syncInterval: 1000 ` for near-real-time with ` client.sync() ` for on-demand freshness.\n3. **LiteFS TXID-based read consistency:** LiteFS exposes the current transaction ID at ` /litefs/.primary ` endpoint. After a write, capture the TXID and pass it via ` Fly-Replay: state=txid_value ` header. The replica will block until it has replicated up to that TXID, giving you read-your-writes consistency without routing all reads to primary.\n4. **SQLite page size tuning:** Default page size is 4096 bytes. For large BLOB-heavy workloads, increase to ` PRAGMA page_size=65536; ` before creating the database (it's immutable after). For small-row OLTP, 4096 is optimal. LiteFS replicates at page granularity, so larger pages mean more data per replicated change.\n\n- sqlite\n- litefs\n- turso\n- libsql\n- edge-computing\n- distributed-databases\n- fly-io\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#title"},{"level":2,"text":"Why SQLite at the Edge Makes Sense","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#why-sqlite-at-the-edge-makes-sense"},{"level":2,"text":"LiteFS: Transparent Replication for SQLite","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#litefs-transparent-replication-for-sqlite"},{"level":3,"text":"Setting Up LiteFS on Fly.io","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#setting-up-litefs-on-flyio"},{"level":3,"text":"Write Forwarding","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#write-forwarding"},{"level":2,"text":"Turso and libSQL: SQLite with Server Capabilities","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#turso-and-libsql-sqlite-with-server-capabilities"},{"level":2,"text":"Real Benchmarks","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#real-benchmarks"},{"level":3,"text":"Read Performance","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#read-performance"},{"level":3,"text":"Write Performance","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#write-performance"},{"level":2,"text":"When to Actually Use This","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#when-to-actually-use-this"},{"level":2,"text":"Advanced: Custom libSQL Extensions","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#advanced-custom-libsql-extensions"},{"level":2,"text":"Operational Reality","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#operational-reality"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#questions"},{"level":3,"text":"Can SQLite handle concurrent writes in production?","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#question-1"},{"level":3,"text":"How does LiteFS replication work?","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#question-2"},{"level":3,"text":"When should I use SQLite over Postgres at the edge?","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#article-tips"}],"sections":[{"heading":"SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer","level":1,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases","content":"Databases"},{"heading":"SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer","level":1,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#title","content":"2025-10-309 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/sqlite-litefs-distributed-edge-databases>)"},{"heading":"Why SQLite at the Edge Makes Sense","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#why-sqlite-at-the-edge-makes-sense","content":"The fundamental insight is this: **if your database is a file on the same machine as your application, reads are a function call, not a network round trip.** The latency difference is extraordinary:\n\n```\nPostgres (same region): ~2ms per query\nPostgres (cross-region): ~50-180ms per query\nSQLite (local file, WAL mode): ~0.02ms per query\n```\n\nThat's not a typo. SQLite reads in WAL mode are literally 100x faster than same-region Postgres because there's no TCP handshake, no protocol negotiation, no query parsing over the wire, no result serialization. It's a B-tree lookup on a memory-mapped file.\n\nThe catch, obviously, is that SQLite is a single-node database. It doesn't replicate. It doesn't cluster. It writes to a single file.\n\nThat's where LiteFS, Turso, and libSQL come in."},{"heading":"LiteFS: Transparent Replication for SQLite","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#litefs-transparent-replication-for-sqlite","content":"[LiteFS](<https://fly.io/docs/litefs/>), built by the [Fly.io](<https://fly.io/>) team, is genuinely clever. It's a FUSE filesystem that sits between your application and the actual SQLite file, intercepting all write operations.\n\nHere's how the replication works at a low level:\n\n```\nApplication writes to SQLite\n ↓\nFUSE intercepts the write syscall\n ↓\nLiteFS captures changed WAL pages\n ↓\nPages are shipped to primary node via HTTP\n ↓\nPrimary applies pages and forwards to replicas\n ↓\nReplicas apply pages to their local SQLite file\n```\n\nThe key design decision is that **LiteFS replicates at the page level, not the SQL level.** It doesn't replay SQL statements, it ships the actual modified 4KB database pages. This means:\n\n1. Replication is SQL-agnostic (works with any SQLite extension, custom functions, etc.)\n2. The overhead is proportional to data changed, not query complexity\n3. There's no need to parse or validate SQL on the replica side"},{"heading":"Setting Up LiteFS on Fly.io","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#setting-up-litefs-on-flyio","content":"```yaml\n# fly.toml\n[mounts]\n source = \"litefs\"\n destination = \"/var/lib/litefs\"\n\n[processes]\n app = \"/usr/local/bin/litefs mount\"\n\n# litefs.yml\nfuse:\n dir: \"/litefs\"\n\ndata:\n dir: \"/var/lib/litefs\"\n\nproxy:\n addr: \":8080\"\n target: \"localhost:3000\"\n db: \"myapp.db\"\n passthrough:\n - \"*.ico\"\n - \"*.png\"\n\nexec:\n - cmd: \"node server.js\"\n\nlease:\n type: \"consul\"\n advertise-url: \"http://${FLY_ALLOC_ID}.vm.${FLY_APP_NAME}.internal:20202\"\n consul:\n url: \"${FLY_CONSUL_URL}\"\n key: \"litefs/${FLY_APP_NAME}\"\n```\n\nThe ` proxy ` section is important, LiteFS runs a reverse proxy that automatically forwards write requests to the primary node. Your application doesn't need to know about the replication topology."},{"heading":"Write Forwarding","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#write-forwarding","content":"When your app running on a replica in Tokyo receives a POST request that needs to write to the database, here's what happens:\n\n```\n1. Request hits LiteFS proxy on Tokyo replica\n2. LiteFS detects it's a write (POST/PUT/DELETE or SQL INSERT/UPDATE/DELETE)\n3. LiteFS forwards the request to the primary (Virginia) via Fly-Replay header\n4. Primary executes the write\n5. Changed pages replicate to Tokyo (~120ms later)\n6. Response returns to client\n```\n\nThe write latency is the network round trip to the primary plus replication time. For a user in Tokyo writing to a Virginia primary, that's roughly 180-250ms. Reads, however, are still hitting the local replica: 0.02ms.\n\nFor a content site where 98% of requests are reads, this trade-off is insanely good."},{"heading":"Turso and libSQL: SQLite with Server Capabilities","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#turso-and-libsql-sqlite-with-server-capabilities","content":"[Turso](<https://turso.tech/>) takes a different approach. Instead of transparent filesystem-level replication, they forked SQLite into libSQL and added server-level features:\n\n```\nlibSQL additions over SQLite:\n├── Server mode (sqld), listens on HTTP/WebSocket\n├── Embedded replicas, local SQLite file that syncs with remote\n├── Namespaces, multi-tenant database isolation\n├── Vector search, native vector embeddings (VECTOR type)\n├── ALTER TABLE improvements, column type changes\n└── WASM-based user-defined functions\n```\n\nThe embedded replica feature is what makes Turso interesting for edge deployments:\n\n```javascript\nimport { createClient } from \"@libsql/client\";\n\nconst client = createClient({\n url: \"file:local-replica.db\", // local SQLite file\n syncUrl: \"libsql://mydb-myorg.turso.io\", // remote primary\n authToken: process.env.TURSO_AUTH_TOKEN,\n syncInterval: 60, // sync every 60 seconds\n});\n\n// Reads hit the local file, ~0.1ms\nconst users = await client.execute(\"SELECT * FROM users WHERE active = 1\");\n\n// Writes go to the remote primary, ~50ms\nawait client.execute({\n sql: \"INSERT INTO users (name, email) VALUES (?, ?)\",\n args: [\"Alice\", \"alice@example.com\"],\n});\n\n// Force sync when you need fresh data\nawait client.sync();\n```\n\nThe sync mechanism is more sophisticated than LiteFS. Turso tracks a vector clock per namespace and only ships the delta of changed frames since the last sync. The embedded replica maintains a local WAL that gets reconciled during sync."},{"heading":"Real Benchmarks","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#real-benchmarks","content":"I ran these against a real-world dataset: 2 million product records, ~800MB database, running on Fly.io machines (shared-cpu-1x, 256MB RAM)."},{"heading":"Read Performance","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#read-performance","content":"```\nQuery: SELECT * FROM products WHERE category = ? AND price < ? ORDER BY rating DESC LIMIT 20\nIndex: CREATE INDEX idx_products_cat_price ON products(category, price)\n\nSQLite (local, WAL mode):\n p50: 0.08ms\n p99: 0.3ms\n Throughput: 42,000 queries/sec (single connection)\n\nTurso (embedded replica, synced):\n p50: 0.09ms\n p99: 0.35ms\n Throughput: 38,000 queries/sec\n\nPostgres (same region, pgbouncer):\n p50: 1.8ms\n p99: 4.2ms\n Throughput: 8,500 queries/sec (50 connections)\n\nPostgres (cross-region, nearest replica):\n p50: 12ms\n p99: 45ms\n Throughput: 2,100 queries/sec\n```\n\nSQLite is 5x faster than same-region Postgres and 150x faster than cross-region. The throughput numbers are even more dramatic because SQLite has no connection overhead, every query is a direct function call into the B-tree."},{"heading":"Write Performance","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#write-performance","content":"```\nQuery: INSERT INTO orders (user_id, product_id, quantity, total) VALUES (?, ?, ?, ?)\n\nSQLite (local, WAL mode):\n p50: 0.05ms\n p99: 2.1ms (WAL checkpoint spikes)\n Throughput: 18,000 inserts/sec (single writer)\n\nLiteFS (write to primary, replicate):\n p50: 85ms (network to primary)\n p99: 210ms\n Throughput: ~200 writes/sec (limited by network RTT)\n\nTurso (embedded replica, write to primary):\n p50: 52ms\n p99: 130ms\n Throughput: ~350 writes/sec\n\nPostgres (same region):\n p50: 1.5ms\n p99: 5ms\n Throughput: 12,000 inserts/sec (parallel writers)\n```\n\nHere's where Postgres wins decisively. SQLite's single-writer constraint means it can't parallelize writes. And in an edge deployment, every write has to hit the primary over the network. If you're doing more than a few hundred writes per second, you need Postgres (or another multi-writer database)."},{"heading":"When to Actually Use This","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#when-to-actually-use-this","content":"After running SQLite at the edge in production for over a year, here's my decision framework:\n\n**Use SQLite + LiteFS/Turso when:**\n\n- Read-to-write ratio is 90%+ reads\n- Dataset fits comfortably on a single machine (\\<100GB)\n- You need sub-millisecond read latency globally\n- You want zero database operational overhead\n- You're on a platform that supports it (Fly.io, Cloudflare Workers with D1, etc.)\n\n**Stick with Postgres when:**\n\n- Write throughput matters (\\>500 writes/sec sustained)\n- You need complex transactions across multiple tables\n- Dataset is large (\\>100GB) or growing fast\n- You need advanced features (JSONB, full-text with custom dictionaries, materialized views)\n- You have a dedicated DBA team\n\n**The hybrid approach** (what I actually do for most projects):\n\n```\n┌─────────────────────────────┐\n│ Edge (Fly.io regions) │\n│ ┌───────────────────────┐ │\n│ │ SQLite (via LiteFS) │ │\n│ │ - User sessions │ │\n│ │ - Content cache │ │\n│ │ - Feature flags │ │\n│ │ - Read-heavy queries │ │\n│ └───────────────────────┘ │\n└─────────────┬───────────────┘\n │ writes + complex queries\n ▼\n┌─────────────────────────────┐\n│ Central (single region) │\n│ ┌───────────────────────┐ │\n│ │ Postgres │ │\n│ │ - Transactional data │ │\n│ │ - Analytics │ │\n│ │ - Full-text search │ │\n│ │ - Write-heavy tables │ │\n│ └───────────────────────┘ │\n└─────────────────────────────┘\n```\n\nSQLite at the edge for the hot, read-heavy path. Postgres in a central region for the transactional, write-heavy stuff. The edge SQLite databases sync from Postgres via a CDC pipeline or periodic ETL."},{"heading":"Advanced: Custom libSQL Extensions","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#advanced-custom-libsql-extensions","content":"One thing that pushed me toward Turso was libSQL's WASM-based user-defined functions. You can write custom functions in any language that compiles to WASM and load them into SQLite:\n\n```rust\n// distance.rs, compiled to WASM\nuse libsql_bindgen::*;\n\n#[libsql_bindgen]\npub fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {\n let r = 6371.0; // Earth's radius in km\n let dlat = (lat2 - lat1).to_radians();\n let dlon = (lon2 - lon1).to_radians();\n let a = (dlat / 2.0).sin().powi(2)\n + lat1.to_radians().cos() * lat2.to_radians().cos()\n * (dlon / 2.0).sin().powi(2);\n let c = 2.0 * a.sqrt().asin();\n r * c\n}\n```\n\nThen use it in SQL:\n\n```sql\nSELECT name, haversine(lat, lon, -34.6037, -58.3816) AS distance_km\nFROM stores\nWHERE distance_km < 10\nORDER BY distance_km;\n```\n\nThis runs at native speed inside the SQLite query engine. No external service calls, no UDF overhead from interpreted languages. For geo queries on edge databases, this is a game-changer."},{"heading":"Operational Reality","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#operational-reality","content":"Let me be honest about the rough edges. SQLite at the edge isn't all sunshine:\n\n1. **Schema migrations are tricky.** You can't run ` ALTER TABLE ... ADD COLUMN ` with a default value on a large table without locking it. libSQL improves this, but vanilla SQLite's ALTER TABLE is famously limited.\n2. **Monitoring is immature.** There's no ` pg_stat_statements ` equivalent. You have to instrument your application code or use SQLite's ` sqlite3_trace_v2 ` callback.\n3. **Backup requires thought.** You can't just ` pg_dump `. LiteFS handles continuous backup to S3, but point-in-time recovery requires keeping WAL files.\n4. **Connection pooling doesn't exist** (because there are no connections). This is actually a feature, no more PgBouncer configuration nightmares, but it means your application must handle SQLite's busy timeout correctly.\n\nDespite these issues, I'm genuinely convinced that SQLite at the edge is the right architecture for a large class of applications that are currently over-provisioning with Postgres or MySQL. The performance characteristics are too good to ignore, and the tooling has finally matured to the point where it's production-ready.\n\nThe database world is having a \"right tool for the right job\" moment. Postgres is exceptional, but not everything needs a client-server database. Sometimes the best database is the one embedded in your application, replicated to every edge node, serving reads in microseconds."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#questions","content":""},{"heading":"Can SQLite handle concurrent writes in production?","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#question-1","content":"SQLite supports one writer at a time, even in WAL mode. For read-heavy workloads (95%+ reads), this is rarely a bottleneck. For write-heavy workloads, you need to serialize writes through a single process or use Turso/libSQL which implements server-side write coordination."},{"heading":"How does LiteFS replication work?","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#question-2","content":"LiteFS uses a FUSE filesystem that intercepts SQLite's WAL writes. When a transaction commits, LiteFS captures the changed pages and ships them to replica nodes over HTTP. Replicas apply the pages to their local copy. Replication lag is typically 50-200ms depending on network distance."},{"heading":"When should I use SQLite over Postgres at the edge?","level":3,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#question-3","content":"Use SQLite when your workload is read-heavy (\\>90% reads), your data fits on a single node (under ~100GB), you need sub-millisecond read latency, and you want zero operational overhead. Use Postgres when you need complex transactions, full-text search with custom dictionaries, or write-heavy workloads."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#article-tips","content":"1. **WAL mode is non-negotiable:** Always run ` PRAGMA journal_mode=WAL; ` before anything else. WAL (Write-Ahead Logging) allows concurrent readers during writes. Without it, a single write locks the entire database. LiteFS requires WAL mode, it won't replicate correctly with rollback journals.\n2. **Turso embedded replicas latency trick:** Use ` @libsql/client ` with ` syncUrl ` and ` syncInterval ` to create a local SQLite replica that syncs with Turso's edge network. Reads hit the local file (~0.1ms), writes go to the primary (~50ms). Set ` syncInterval: 1000 ` for near-real-time with ` client.sync() ` for on-demand freshness.\n3. **LiteFS TXID-based read consistency:** LiteFS exposes the current transaction ID at ` /litefs/.primary ` endpoint. After a write, capture the TXID and pass it via ` Fly-Replay: state=txid_value ` header. The replica will block until it has replicated up to that TXID, giving you read-your-writes consistency without routing all reads to primary.\n4. **SQLite page size tuning:** Default page size is 4096 bytes. For large BLOB-heavy workloads, increase to ` PRAGMA page_size=65536; ` before creating the database (it's immutable after). For small-row OLTP, 4096 is optimal. LiteFS replicates at page granularity, so larger pages mean more data per replicated change.\n\n- sqlite\n- litefs\n- turso\n- libsql\n- edge-computing\n- distributed-databases\n- fly-io\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/sqlite-litefs-distributed-edge-databases"},{"text":"LiteFS","url":"https://fly.io/docs/litefs/"},{"text":"Fly.io","url":"https://fly.io/"},{"text":"Turso","url":"https://turso.tech/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases"},"headline":"SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer","description":"A practical guide to running SQLite as a distributed edge database. LiteFS replication internals, libSQL extensions, Turso's architecture, and real benchmarks showing when SQLite obliterates Postgres for read-heavy edge workloads.","datePublished":"2025-10-30","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Databases","keywords":"sqlite, litefs, turso, libsql, edge-computing, distributed-databases, fly-io","wordCount":1554,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/sqlite-litefs-distributed-edge-databases"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"SQLite at the Edge: LiteFS, Turso, and Why Postgres Isn't Always the Answer","item":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can SQLite handle concurrent writes in production?","acceptedAnswer":{"@type":"Answer","text":"SQLite supports one writer at a time, even in WAL mode. For read-heavy workloads (95%+ reads), this is rarely a bottleneck. For write-heavy workloads, you need to serialize writes through a single process or use Turso/libSQL which implements server-side write coordination."}},{"@type":"Question","name":"How does LiteFS replication work?","acceptedAnswer":{"@type":"Answer","text":"LiteFS uses a FUSE filesystem that intercepts SQLite's WAL writes. When a transaction commits, LiteFS captures the changed pages and ships them to replica nodes over HTTP. Replicas apply the pages to their local copy. Replication lag is typically 50-200ms depending on network distance."}},{"@type":"Question","name":"When should I use SQLite over Postgres at the edge?","acceptedAnswer":{"@type":"Answer","text":"Use SQLite when your workload is read-heavy (>90% reads), your data fits on a single node (under ~100GB), you need sub-millisecond read latency, and you want zero operational overhead. Use Postgres when you need complex transactions, full-text search with custom dictionaries, or write-heavy workloads."}}]}]}],"contentHash":"26ca10ac516a406db6e41f66642878eee929acb8601c78aca078c08c5dbe8e59","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/sqlite-litefs-distributed-edge-databases","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":66910,"contentBytes":14197}}
