DEV Community

Cover image for SQLite as Your Server Database: WAL Mode, PRAGMA Tuning, and Why Litestream Changes Everything for Solo Founders
SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

SQLite as Your Server Database: WAL Mode, PRAGMA Tuning, and Why Litestream Changes Everything for Solo Founders

---
title: "SQLite as Your Server Database: WAL Mode, PRAGMA Tuning, and Litestream Replication"
published: true
description: "A hands-on guide to running SQLite in production with WAL mode, seven critical PRAGMAs, single-writer discipline, and Litestream backup  with benchmarks showing 2-5x read performance over Postgres."
tags: architecture, backend, cloud, mobile
canonical_url: https://mvpfactory.co/blog/sqlite-server-database-wal-pragma-litestream
---

## What We Are Building

By the end of this tutorial, you will have a production-ready SQLite setup for a read-heavy mobile backend: WAL mode enabled, seven PRAGMAs tuned, a single-writer connection pattern implemented, and Litestream replicating your data to S3. This configuration handles 5,000–10,000 requests/second on a $20/month VPS.

Let me show you a pattern I use in every early-stage project.

## Prerequisites

- A Linux VPS (any provider — Hetzner, DigitalOcean, Fly.io)
- Node.js 18+ with `better-sqlite3`
- Litestream installed ([litestream.io](https://litestream.io))
- An S3-compatible bucket for backups

## Step 1: Enable WAL Mode and Tune Your PRAGMAs

Default SQLite is tuned for embedded devices. These seven PRAGMAs are the difference between "this is slow" and "wait, that is SQLite?"

Enter fullscreen mode Exit fullscreen mode


sql
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA mmap_size = 268435456;
PRAGMA cache_size = -64000;
PRAGMA busy_timeout = 5000;
PRAGMA wal_autocheckpoint = 1000;
PRAGMA foreign_keys = ON;


Set these **on every connection open**. `journal_mode=WAL` is the critical one — without it, readers block writers and your API locks under load. `synchronous=NORMAL` gives you roughly a 2x write speedup over `FULL` while maintaining durability guarantees with WAL.

Here is the minimal setup to get this working:

Enter fullscreen mode Exit fullscreen mode


javascript
import Database from 'better-sqlite3';

function openDb(path, readonly = false) {
const db = new Database(path, { readonly });
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('mmap_size = 268435456');
db.pragma('cache_size = -64000');
db.pragma('busy_timeout = 5000');
db.pragma('wal_autocheckpoint = 1000');
db.pragma('foreign_keys = ON');
return db;
}


## Step 2: Enforce Single-Writer Discipline

One writer at a time. That is the constraint that makes everything else work. WAL allows concurrent reads alongside a single writer, but two simultaneous writes serialize via `busy_timeout`.

Enter fullscreen mode Exit fullscreen mode


javascript
const writeDb = openDb('/data/app.db');
const readPool = Array.from({ length: 4 }, () =>
openDb('/data/app.db', true)
);

// Route all mutations through the single writer
function write(fn) {
return fn(writeDb);
}

// Round-robin reads across the pool
let readIndex = 0;
function read(fn) {
const db = readPool[readIndex++ % readPool.length];
return fn(db);
}


One write connection behind a mutex. Four to eight read connections in a pool. Route explicitly. This is not optional — violate it and you get `SQLITE_BUSY` errors at 2 AM.

## Step 3: Configure Litestream for Continuous Replication

Litestream streams WAL changes to S3 with sub-second lag. Create `litestream.yml`:

Enter fullscreen mode Exit fullscreen mode


yaml
dbs:


Overhead: under 1ms per transaction. Cost: roughly $0.50/month for a 1GB database. Restoring is one command:

Enter fullscreen mode Exit fullscreen mode


bash
litestream restore -o /data/app.db s3://my-app-backups/replica


Compare that to $50+/month for managed Postgres with point-in-time recovery.

## Gotchas

**Forgetting PRAGMAs on new connections.** Every connection starts with defaults. If your connection pool opens a new handle without running all seven PRAGMAs, that connection runs with embedded-device settings. Wrap it in a factory function.

**Using WAL without `busy_timeout`.** Without it, concurrent write attempts fail immediately instead of waiting. Set it to at least 5000ms.

**Assuming you can skip single-writer discipline.** The docs do not mention this, but "WAL allows concurrent access" does not mean concurrent *writes*. It means readers no longer block the writer. Two writers still serialize. Architect for it or suffer intermittent failures under load.

**Ignoring migration signals.** Watch for sustained writes above 500 TPS, database size past 50–100GB, or multi-region requirements. Design behind a repository interface from day one — when you graduate to Postgres, the migration is mechanical, not architectural.

## Conclusion

SQLite with WAL, tuned PRAGMAs, and Litestream handles read-heavy mobile backends under 1M users while eliminating connection pooling, network round-trips, and managed database costs. Each layer of infrastructure you do not run is a layer you do not debug, pay for, or wake up to fix.

Start here. You will know when you have outgrown it.

**Resources:**
- [SQLite WAL documentation](https://www.sqlite.org/wal.html)
- [Litestream Getting Started](https://litestream.io/getting-started/)
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)