TL;DR:

  • SQLite is now viable for many production web applications that previously required a client-server database like Postgres
  • Litestream solves the backup problem by continuously streaming SQLite changes to S3 or similar object storage
  • Turso distributes SQLite globally with edge replicas, sub-millisecond reads, and a generous free tier

A few years ago, the standard advice was clear: SQLite is for development, testing, and embedded applications. For anything exposed to the internet with real traffic, you ran Postgres or MySQL on a server. This was so widely accepted it barely warranted discussion.

That consensus is cracking. A growing number of production applications run SQLite, including some handling substantial traffic. The tooling — particularly Litestream and Turso — has addressed the main objections, and a cultural shift in the Rails and indie developer communities has made SQLite feel legitimate in a way it didn’t before.

This is worth understanding properly, because the right choice still depends heavily on your workload.

Why SQLite Was Dismissed

The traditional objections to SQLite in production are real:

No network access. SQLite is a library, not a server. The database file lives on the same machine as the application. This rules it out for any architecture where multiple application instances need to write to the same database.

Write concurrency. SQLite uses file-level locking. Under heavy concurrent writes, requests queue behind each other, causing latency spikes and timeouts. Read performance is excellent — concurrent reads are fast — but write-heavy workloads suffer.

No built-in replication or backup. You can’t run a Postgres-style hot standby with SQLite. If the disk fails, you lose data unless you’ve set up explicit backups.

Disk colocation. Moving the database means moving the file. Migrating to a larger instance requires downtime unless you’ve set up something more sophisticated.

Each of these is a genuine limitation. But for a large class of applications — particularly smaller SaaS products, internal tools, single-tenant applications, and anything read-heavy — they’re either irrelevant or solvable.

Litestream: Continuous Replication to S3

Litestream is the tool that changed the backup objection. It’s a standalone process that watches a SQLite database file and continuously streams changes to an S3-compatible object store (AWS S3, Cloudflare R2, Backblaze B2). The writes happen in real time, providing point-in-time recovery with sub-second granularity.

Running Litestream alongside your application looks like this:

# litestream.yml
dbs:
  - path: /data/myapp.db
    replicas:
      - type: s3
        bucket: myapp-db-backups
        path: db
        region: eu-west-1
# Run alongside your app
litestream replicate -config litestream.yml &
./myapp serve

To restore from a failure:

litestream restore -o /data/myapp.db \
  s3://myapp-db-backups/db

The data is there. Recovery is fast. The disk-failure objection is largely gone. Litestream adds minimal overhead — it reads the SQLite WAL file rather than intercepting writes, so your application doesn’t notice it’s there.

Turso: Distributed SQLite at the Edge

Turso takes a different approach. Rather than running SQLite locally with a replication sidecar, Turso provides hosted SQLite databases with a libSQL fork that adds network access and edge distribution.

The architecture: a primary database (writable) plus read replicas at edge locations close to users. Reads hit the nearest replica; writes go to the primary. For read-heavy applications with a global user base, this gives you sub-millisecond reads from most locations without the operational complexity of running your own read replicas.

import { createClient } from "@libsql/client";

const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
});

const result = await db.execute({
  sql: "SELECT * FROM posts WHERE user_id = ?",
  args: [userId],
});

The API is standard SQL. Existing SQLite queries work without modification. The main constraint is that you’re writing through Turso’s libSQL protocol rather than directly to a local file, which means you need network access and can’t use some SQLite-specific tools that assume local file access.

Turso’s pricing is developer-friendly: the free tier includes 500 databases and 1 billion row reads per month, which covers a surprising number of real applications.

When SQLite Makes Sense

The honest picture of when you should consider SQLite in production:

Good fit:

  • Single-process applications (one app server instance handles all traffic)
  • Read-heavy workloads (SQLite reads are extremely fast)
  • Applications where write concurrency is low (most CRUD apps aren’t actually write-heavy)
  • Per-tenant or per-user databases (an isolated SQLite file per customer scales beautifully)
  • Edge deployments where you want data close to the user
  • Projects where simplicity and operational ease matter more than maximum scalability

Poor fit:

  • Multiple write-heavy application instances all accessing the same database
  • Applications requiring Postgres-specific features (full-text search via pg_trgm, PostGIS, JSONB, etc.)
  • Workloads where write concurrency is genuinely high and latency-sensitive
  • Teams with strong Postgres expertise and existing tooling

The per-tenant model deserves particular attention. If your application is multi-tenant and can shard data per customer (common in B2B SaaS), SQLite shines. Each customer gets their own database file, completely isolated. There’s no query interference between tenants, schema migrations can be rolled out tenant-by-tenant, and individual tenant databases are trivially portable. Turso’s multi-database architecture is explicitly designed for this pattern.

The Rails Connection

Much of the renewed enthusiasm for SQLite in production has come from the Ruby on Rails community, where DHH (creator of Rails) has been an active advocate. Rails 8 added native SQLite support improvements including better WAL configuration, connection pooling adjustments, and Solid Queue — a database-backed job queue that works well with SQLite.

The argument isn’t that SQLite is always better than Postgres. It’s that for many applications — particularly those starting out or running as solo/small-team projects — the operational simplicity of SQLite (no separate database process, no connection pool management, backups via Litestream) is a meaningful advantage that offsets the concurrency limitations.

Performance in Practice

Raw SQLite performance surprises developers who haven’t benchmarked it. A modern SSD can handle tens of thousands of SQLite writes per second in WAL mode. Reads can exceed hundreds of thousands per second. For most web applications, SQLite is not the bottleneck.

The limitation hits when writes need to happen simultaneously from multiple goroutines/threads and you can’t batch them. In WAL mode SQLite allows one writer at a time, so concurrent writes queue. At moderate concurrency (tens of concurrent writers), latency starts to climb.

The practical approach: run SQLite with WAL enabled, increase the busy timeout so requests wait rather than immediately failing, and batch writes where possible. Most applications that have made this switch find the write concurrency limits are never actually reached in practice.

// Go: configure SQLite properly for concurrent access
db, _ := sql.Open("sqlite3", "file:myapp.db?_journal_mode=WAL&_busy_timeout=5000")
db.SetMaxOpenConns(1) // Single writer
db.SetMaxIdleConns(runtime.NumCPU()) // Multiple readers

The SQLite renaissance is real and worth paying attention to, even if Postgres remains the right choice for many production systems. Understanding when the embedded approach works — and when it doesn’t — is increasingly part of making good architectural decisions.