How to Handle the Session and Cache Layer of a Single-Instance Backend with Bun 1.2's Built-in SQLite
Every time I started a new project, the same pattern repeated itself. "How do I handle session storage? → Let's add Redis → Sign up for Upstash → Set environment variables → Worry about cold starts..." This loop felt so familiar I took it for granted — until I started using Bun 1.2 and began thinking differently.
Let me clarify the terminology upfront. This article focuses on Bun servers running on a single always-on container, like Fly.io or Railway. Strictly speaking, this is not "serverless functions" in the FaaS sense (AWS Lambda, Vercel Functions, Cloudflare Workers). In FaaS environments, processes are frozen or recycled between requests, making in-memory SQLite unreliable as a persistent store. Additionally, as explained later, native bindings simply don't run on Cloudflare Workers. This article therefore covers the practical pattern of "finishing everything with SQLite inside a single process, instead of attaching an external session store."
Since Bun 1.2 (already a fairly old release as of July 2026), bun:sqlite has been built into the runtime, making it possible to use SQLite as a session and cache store within a single process — no npm install, no network round-trips. In in-memory mode (new Database(":memory:")), it can adequately replace an external Redis for simple K-V TTL caching purposes (Redis advanced features like sorted sets, pub/sub, and Lua scripting are absent — more on that later).
This article covers three things: an in-memory session store for always-on servers, a TTL-based result cache (including Cache Stampede mitigation), and file-based sessions on a Fly.io persistent volume. It also covers where to stop.
Why Re-examine SQLite-first Now
The equation session = Redis was long-held conventional wisdom, but it has been quietly shifting over the past few years.
After the Redis license change, forks like Valkey emerged, and separate projects like Dragonfly — rewritten from scratch in C++ but compatible with the Redis protocol — have established a foothold, making the choices more complex. In this climate, the question "for a single-instance service, do we really need an external K-V store?" is being asked again, and confidence in SQLite has grown as Cloudflare D1 exposes SQLite semantics in edge runtimes.
Node.js has also experimentally bundled node:sqlite since v22.5.0. The trend of embedding SQLite at the runtime level is not unique to Bun.
Characteristics of bun:sqlite
bun:sqlite provides a synchronous interface through native bindings written in Zig. Because it inherits the API design of better-sqlite3, the learning curve for migrating existing Node.js code is low. One practically appealing aspect is that db.query() automatically caches compiled SQL bytecode per instance. By contrast, db.prepare() does not use automatic caching, so for statements that need to run repeatedly, the pattern is to prepare them once at the top of the module and reuse them. All code in this article follows the latter approach.
Performance varies significantly by workload, so I won't commit to a specific "N times faster" number here. For real benchmarks, consult the Bun official SQLite docs and the Bun 1.2 release notes against your own workload conditions.
import { Database } from "bun:sqlite";
const db = new Database(":memory:");Two Modes, Two Situations
There are broadly two approaches to using SQLite as a session and cache store.
In-memory mode is a volatile cache with the same lifetime as the process. It suits data you can afford to lose: expiring OTP codes, rate-limiting counters, external API response caches. File-based mode is for mounting a persistent volume to preserve data across restarts. It is critical to remember that placing the file at a path inside the container without a volume means it resets on every redeploy.
Pattern 1: In-Memory Session Store for Always-On Servers
A pattern for managing JWT-auxiliary sessions or temporary OTP codes within a single Bun process.
import { Database } from "bun:sqlite";
const db = new Database(":memory:");
db.run(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
const upsert = db.prepare(
"INSERT OR REPLACE INTO sessions VALUES ($id, $data, $expires)"
);
const get = db.prepare(
"SELECT data FROM sessions WHERE id = $id AND expires_at > $now"
);
const del = db.prepare("DELETE FROM sessions WHERE id = $id");
export function setSession(id: string, data: unknown, ttlMs: number) {
upsert.run({
$id: id,
$data: JSON.stringify(data),
$expires: Date.now() + ttlMs,
});
}
export function getSession<T>(id: string): T | null {
const row = get.get({ $id: id, $now: Date.now() }) as { data: string } | null;
return row ? (JSON.parse(row.data) as T) : null;
}
export function deleteSession(id: string) {
del.run({ $id: id });
}There is no await. The first time I saw this I thought "is this right?" — it only clicked once I understood that bun:sqlite's synchronous design is an intentional choice to eliminate N-API overhead.
Expiration is already handled by the expires_at > $now condition in the read query, so there is no logical problem even without a separate cleanup job. That said, you may want to prevent the in-memory table from growing indefinitely. The commonly used setInterval pattern works fine on always-on servers (Fly.io/Railway containers), but keep in mind it cannot be trusted in true FaaS environments where processes are frozen between requests.
const cleanup = db.prepare("DELETE FROM sessions WHERE expires_at <= $now");
setInterval(() => {
cleanup.run({ $now: Date.now() });
}, 60_000);If you need to move to a FaaS environment, running this cleanup probabilistically within the request handling path (e.g., clean up 1 out of every 100 requests) or delegating it to a separate cron job is safer.
Pattern 2: TTL Result Cache and Cache Stampede Mitigation
A pattern for storing results from external APIs or slow DB queries in SQLite. The key is handling expiration checks as SQL conditions.
import { Database } from "bun:sqlite";
const db = new Database(":memory:");
db.run(`
CREATE TABLE cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
const cacheGet = db.prepare(
"SELECT value FROM cache WHERE key = $key AND expires_at > $now"
);
const cacheSet = db.prepare(
"INSERT OR REPLACE INTO cache VALUES ($key, $value, $expires)"
);The simplest form looks like this.
export async function withCache<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = cacheGet.get({ $key: key, $now: Date.now() }) as
| { value: string }
| null;
if (cached) return JSON.parse(cached.value) as T;
const result = await fetcher();
cacheSet.run({
$key: key,
$value: JSON.stringify(result),
$expires: Date.now() + ttlMs,
});
return result;
}There is one issue to address here. SQL execution is synchronous, but fetcher() is asynchronous — so when the cache expires and multiple requests for the same key arrive simultaneously, all of them will call fetcher(). This is known as a Cache Stampede. It goes unnoticed under low load, but even a small traffic spike can hammer the backend.
For in-process protection, an in-flight map that holds the in-progress Promise per key is sufficient.
const inflight = new Map<string, Promise<unknown>>();
export async function withCache<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = cacheGet.get({ $key: key, $now: Date.now() }) as
| { value: string }
| null;
if (cached) return JSON.parse(cached.value) as T;
const existing = inflight.get(key) as Promise<T> | undefined;
if (existing) return existing;
const pending = (async () => {
try {
const result = await fetcher();
cacheSet.run({
$key: key,
$value: JSON.stringify(result),
$expires: Date.now() + ttlMs,
});
return result;
} finally {
inflight.delete(key);
}
})();
inflight.set(key, pending);
return pending;
}The request flow looks like this.
Pattern 3: Fly.io Persistent Volume with File Mode
If sessions must survive restarts, combine file-based mode with a persistent volume. Here is the volume mount section in fly.toml.
[mounts]
source = "sqlite_data"
destination = "/data"In the Bun code, use the mounted path.
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
mkdirSync("/data", { recursive: true });
const db = new Database("/data/sessions.db");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);The reason for CREATE TABLE IF NOT EXISTS is that the table already exists after a restart. Unlike in-memory mode, initialization code must be idempotent. I once left this out and got an error on every restart.
One more thing to note: when WAL mode is enabled, SQLite creates /data/sessions.db-wal and /data/sessions.db-shm alongside /data/sessions.db. If your volume backup script copies only the .db file, consistency can break — omitting those two files during a data migration will lose any writes since the last checkpoint. The recommended approach is to call PRAGMA wal_checkpoint(TRUNCATE) before copying to merge the WAL into the main file, or to snapshot all three files together. Note that WAL mode itself cannot be used on NFS mounts, so check this in advance if you plan to use that type of storage.
Where to Stop
Clearly distinguishing when this pattern fits and when it does not is the most important part of this article.
| Item | In-memory mode | File-based mode | Notes |
|---|---|---|---|
| Persistence | None (process lifetime) | Yes (volume-dependent) | |
| Horizontal scaling | Not supported | Not supported | Cannot share across multiple instances |
| WAL mode | N/A | Supported | Not usable on NFS |
| Cloudflare Workers | Not supported | Not supported | Must switch to D1 |
| Write concurrency | Single writer | Single writer | Bottleneck for write-heavy workloads |
| Configuration complexity | Very low | Low (volume setup required) |
Not suitable for multi-instance environments. When scaled out to multiple instances, each has its own independent SQLite. If a session is stored on instance 1 and the next request is routed to instance 2, the session will not be found. At that point, Upstash Redis or an external DB is the right choice.
bun:sqlite does not work on Cloudflare Workers. Native bindings cannot execute in the Workers runtime. For Cloudflare environments, you must switch to D1 — with one important caveat. D1 is a Promise-based asynchronous API. Migrating synchronous bun:sqlite or better-sqlite3 code to D1 is not just a matter of adding await — it is closer to redesigning transaction boundaries, error handling, and control flow from scratch. If you are designing an interface with the assumption that you will "migrate someday," it is better to wrap it in an async signature from the start.
I will not use the phrase "Redis-level cache." SQLite in-memory is an excellent substitute for simple K-V TTL caching, but it lacks Redis primitives like pub/sub, atomic INCR/SETNX, sorted sets, and Lua scripting. If you need real-time leaderboards, distributed locks, or event fan-out, Redis (or an alternative server) is still the right choice.
A Common Mistake: Data Lost on Deployment
On Fly.io or Railway, placing a SQLite file at a path inside the container without a persistent volume means data is reset on every redeploy. This is quite alarming the first time it happens. The volume mount path and idempotent initialization scripts are not optional.
Decision Flow
To summarize:
If you are running a single instance, not on Cloudflare Workers, and a simple K-V TTL cache meets your needs, bun:sqlite provides a sufficient session and cache layer without an external store. Zero dependencies, zero network round-trips, synchronous API. Fewer things to configure means fewer things to get wrong.
Things Worth Trying Next
If you take away one action from this article, I recommend listing the Redis primitives you actually use in the session and cache code of a service you currently run. If the list ends at SET/GET/EXPIRE/DEL, it is a good candidate for migrating to bun:sqlite in-memory mode and removing one piece of infrastructure. Conversely, if INCR, ZADD, or pub/sub are in there, the patterns in this article are not the right fit — don't force the migration.