Eliminating external DB round-trips in a resident edge process using bun:sqlite with WAL mode
Working with external databases in serverless or edge functions often leads to a nerve-wracking experience at every cold start. When runtime initialization time collides with resetting the DB connection pool and the network round trip to a geographically distant DB, the first request can slip by hundreds of milliseconds — sometimes close to a full second. At first I just accepted it as an "unavoidable tax," but after working with Bun 1.2 and bun:sqlite, my perspective changed considerably.
bun:sqlite runs the HTTP server and SQLite in the same process, with no separate database server process. Because a DB connection is essentially a local file handle, there is no connection establishment time at all. Enable WAL mode and reads and writes no longer block each other, giving you surprisingly good concurrency even within a single instance.
This article covers how bun:sqlite works under the hood, the practical impact of WAL mode, code patterns, and when to migrate to PostgreSQL or a distributed SQLite solution. If you're running a resident-process edge runtime (Fly.io Machine, your own container, etc.) and want to reduce dependence on external DBs, this is worth a read.
First Things First — "Serverless" and "Edge" Are Not the Same
Let me pin down the execution environments where this approach actually works. To write data to a local file with bun:sqlite, the process must be resident and have a persistent filesystem.
- In fully ephemeral environments like AWS Lambda or Vercel Functions, the SQLite file disappears when the function exits. You can write to
/tmp, but the file is not shared between instances and there is no guarantee of how long it will survive. - By contrast, this works correctly in resident-process setups with attached volumes like Fly.io Machines, custom container orchestration, or edge workloads that maintain local state.
- Cloudflare Workers provides D1 and Durable Objects SQLite as separate offerings, which are architecturally distinct from the
bun:sqlitefile access described here.
In other words, this article is not about "cold starts magically disappearing in lambda-style serverless." It is about eliminating the round trip to an external DB in resident-process edge and lightweight backends.
Why SQLite Is Back on the Production Stage
The "Embedded DB" Perception Is Shifting
Until fairly recently I treated SQLite as something only for tests and prototypes. But over the past few years, Turso (libSQL), Cloudflare D1, and Fly.io LiteFS have all reached production maturity in parallel, making distributed SQLite using WAL-based replication a serious option. Some communities have floated phrases like "Post-PostgreSQL," but that is closer to overstated marketing language; the accurate framing is more like "SQLite has become an attractive choice again for certain workloads."
Local File Access and Remote DB Round Trips Are Fundamentally Different Layers
The biggest cost of an external DB architecture is not the DB engine itself — it is network round trips and connection management. A local PostgreSQL responds at microsecond latency. The problem is that the remote managed DBs commonly used with serverless and edge are geographically distant, and the round trip alone eats tens of milliseconds.
bun:sqlite's local-first approach eliminates that round-trip layer entirely.
The Bun runtime's own process startup time is reported at around 8 ms. However, those 8 ms represent Bun's process startup time itself — using bun:sqlite does not mean the entire application cold start becomes 8 ms. Real-world service cold starts add server initialization, schema migration, and warm-up query time on top of that. Even so, eliminating the external DB connection establishment round trip is a substantial saving in its own right.
bun:sqlite and WAL Mode — Core Concepts
How bun:sqlite Differs from better-sqlite3
bun:sqlite provides a synchronous API inspired by better-sqlite3. There is no npm install — a single line, import { Database } from "bun:sqlite", is all you need. It is statically linked into the Bun binary rather than a native add-on, so there is no risk of package compatibility issues.
The commonly cited benchmark of "4–6x faster than better-sqlite3 and 8–9x faster than Deno SQLite" comes from a Bun 1.2 community benchmark. Since the query types, data sizes, and hardware conditions used are not disclosed, it is better to run your own measurements on your specific workload before relying on those numbers. The API is synchronous, but because it runs on top of local file I/O it rarely becomes a bottleneck in real workloads.
Why WAL Mode Is Necessary
In SQLite's default journal mode (rollback mode), reads block during writes. In a server environment handling concurrent requests, this becomes a bottleneck. WAL (Write-Ahead Logging) writes changes to a WAL file first and merges them into the main DB file at checkpoint time, so reads and writes do not block each other.
The official Bun documentation states that WAL mode can achieve approximately 70,000 reads/s and 3,600 writes/s. Enabling it takes just one line.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run("PRAGMA journal_mode = WAL");One caveat: WAL depends on a WAL file on the filesystem. For an :memory: DB, SQLite ignores the WAL setting and stays in memory mode. There is no reason to set WAL on an in-memory database.
From Setup to High-Throughput Patterns
Basic Setup
import { Database } from "bun:sqlite";
const db = new Database("./data/app.db", { create: true });
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA foreign_keys = ON");
db.run(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT,
created_at INTEGER DEFAULT (unixepoch())
)
`);PRAGMA synchronous = NORMAL improves performance but can result in the loss of the most recent transaction in the event of an OS crash. If data durability is your top priority, keeping the default of FULL is the safer choice.
wal_autocheckpoint frequently appears in tuning examples, but since the default value is already 1000 pages, explicitly setting it to that value is meaningless. Only adjust it when you actually need to increase or decrease checkpoint frequency — for instance, lowering the value on a very write-heavy workload.
Integration with the Bun HTTP Server
import { Database } from "bun:sqlite";
const db = new Database("./data/app.db", { create: true });
db.run("PRAGMA journal_mode = WAL");
const insertEvent = db.query(
"INSERT INTO events (type, payload) VALUES ($type, $payload) RETURNING id"
);
const getRecentEvents = db.query(
"SELECT * FROM events ORDER BY created_at DESC LIMIT $limit"
);
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/events") {
const body = await req.json();
const result = insertEvent.get({
$type: body.type,
$payload: JSON.stringify(body.payload),
});
return Response.json(result);
}
if (req.method === "GET" && url.pathname === "/events") {
const limit = Number(url.searchParams.get("limit") ?? "20");
const events = getRecentEvents.all({ $limit: limit });
return Response.json(events);
}
return new Response("Not Found", { status: 404 });
},
});db.query() caches and reuses PreparedStatements for the same SQL string, so the parsing cost is not incurred on every request.
Transactions Are Essential for Bulk Inserts
Individual INSERTs without a transaction trigger an fsync on every commit, which is extremely slow (whether synchronous is FULL or NORMAL, there is a disk sync cost at each commit point). Wrapping them in db.transaction() processes everything as a single commit, bringing the time down to the millisecond range.
interface LogEntry {
level: string;
message: string;
ts: number;
}
const insertLog = db.query(
"INSERT INTO logs (level, message, ts) VALUES ($level, $message, $ts)"
);
const bulkInsert = db.transaction((entries: LogEntry[]) => {
for (const entry of entries) {
insertLog.run({
$level: entry.level,
$message: entry.message,
$ts: entry.ts,
});
}
});
bulkInsert(largeLogBatch);In-Memory DB for In-Process State Management
Useful for managing short-lived caches or session state within an instance without an external Redis. As noted earlier, WAL does not apply to :memory: databases, so don't bother setting it.
import { Database } from "bun:sqlite";
const cache = new Database(":memory:");
cache.run(`
CREATE TABLE kv (
key TEXT PRIMARY KEY,
value TEXT,
expires_at INTEGER
)
`);
const setCache = cache.query(
"INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES ($key, $value, $exp)"
);
const getCache = cache.query(
"SELECT value FROM kv WHERE key = $key AND (expires_at IS NULL OR expires_at > unixepoch())"
);
function set(key: string, value: unknown, ttlSeconds?: number) {
setCache.run({
$key: key,
$value: JSON.stringify(value),
$exp: ttlSeconds ? Math.floor(Date.now() / 1000) + ttlSeconds : null,
});
}
function get<T>(key: string): T | null {
const row = getCache.get({ $key: key }) as { value: string } | null;
return row ? JSON.parse(row.value) : null;
}Drizzle ORM Integration
If you need type safety, you can layer on Drizzle ORM, which is also mentioned in the Bun docs.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { desc } from "drizzle-orm";
const sqlite = new Database("app.db");
sqlite.run("PRAGMA journal_mode = WAL");
const db = drizzle(sqlite);
const events = sqliteTable("events", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").notNull(),
payload: text("payload"),
createdAt: integer("created_at", { mode: "timestamp" }),
});
const recent = db
.select()
.from(events)
.orderBy(desc(events.createdAt))
.limit(10)
.all();Trade-offs — Which Workloads Fit, and When to Migrate
Qualitative Comparison
| Aspect | Local bun:sqlite (WAL) | Remote Managed DB |
|---|---|---|
| Access path | Same-process file handle | Network round trip |
| Connection establishment | None (file open) | TCP + auth round trip |
| Throughput ceiling (WAL) | ~70,000 reads/s · ~3,600 writes/s | Depends on DB spec and link bandwidth |
| Dependencies | Built into Bun runtime | Driver, pool, network |
| Scale-out | Single-instance focused | Natural multi-instance sharing |
These numbers are better read as reflecting the difference in access path (local file vs. network) rather than raw DB engine performance. A PostgreSQL on the same node — not remote — would have latency comparable to SQLite.
Conditional Downsides of WAL Mode
WAL is not always the better choice. In write-only workloads driven by a single client, rollback mode can actually outperform WAL due to WAL file management overhead. How significant the difference is depends on both the disadvantages section of the official SQLite WAL documentation and your own workload measurements — consult both before deciding.
Platform-specific behavior differences also exist. Bun uses the system SQLite on macOS but statically links its own built SQLite on Linux. This means the SQLite version can differ, which may affect PRAGMA defaults and extension support, so verify on your actual production platform. (For reference, the WAL file retention policy itself follows standard SQLite behavior regardless of platform — the WAL file is retained until a full checkpoint completes.)
Suitable and Unsuitable Workloads
| Good fit | Avoid |
|---|---|
| Local cache for resident edge process | Multiple containers sharing the same file |
| Temporary in-instance state | Multiple write processes |
| Read-heavy API | Large datasets (tens of GB or more) |
| Resident job queue state storage | Payment/accounting domains with strict RPO requirements |
| Offline-first clients | Ephemeral serverless execution environments (Lambda, etc.) |
Common Mistakes
Inserting in a loop without a transaction. An fsync fires on every commit, causing throughput to differ by orders of magnitude. Wrap inserts with db.transaction().
Neglecting WAL checkpointing. If the WAL file grows without bound, performance degrades. Run PRAGMA wal_checkpoint(TRUNCATE) manually during low-traffic windows, or tune wal_autocheckpoint to match your workload characteristics.
Overusing synchronous = OFF. The DB file itself can become corrupted on an OS crash. NORMAL is within acceptable risk; OFF is best avoided.
When You Need to Go Beyond a Single Node
The limitations of bun:sqlite are clear. It allows only a single write process, and multiple containers cannot share the same file. When horizontal scaling becomes necessary, there are two directions.
Turso (libSQL) — An open-source fork of SQLite with WAL streaming replication added. In embedded replica mode, you can read locally much like bun:sqlite while routing writes to a remote primary.
Cloudflare D1 — SQLite integrated into the Workers ecosystem. It has capacity limits, however, and is tied to the Workers runtime.
Migrating from bun:sqlite to the libSQL family is relatively low-friction because the API shapes are similar. Rather than adopting a distributed option from the start, a perfectly reasonable strategy is to begin with bun:sqlite and migrate only when an actual bottleneck is observed.
Signals That Tell You It's Time to Migrate
To summarize: bun:sqlite is worth viewing not merely as "a fast way to use SQLite" but as an architectural choice that eliminates the round trip to an external DB in resident edge processes. WAL mode provides read/write concurrency within a single node, and using db.transaction() correctly handles bulk inserts with ease.
If you find the judgment call difficult in practice, setting these triggers helps:
- Consider adopting now — when you have a resident process, a read-heavy single-node workload, and a predictable data size (within a few GB).
- Signal to migrate to distributed SQLite (Turso/D1) — when multi-region read latency starts becoming noticeable to users, or when a traffic pattern emerges that embedded replicas would solve.
- Signal to migrate to managed PostgreSQL — when multiple instances need to share the same write path, when write QPS approaches the limits of a single process, or when the dataset grows to tens of GB.
- Cases where a different choice is better from the start — fully ephemeral execution environments like AWS Lambda, or domains with strict RPO requirements.
Trigger.dev's case study reporting throughput improvements after migrating from Node.js to Bun is frequently cited as a reference. There is no guarantee those numbers apply directly to your workload, so always base your decisions on measurements against your own traffic patterns.