What Bun.serve and bun:sqlite give you out of the box
This tutorial is for backend and platform engineers who need a small internal service, such as a lookup API, a webhook receiver or an inventory of internal resources, and would rather not maintain a dependency tree for it. Bun ships an HTTP server (Bun.serve) and a SQLite driver (bun:sqlite) as part of the runtime. A working REST API needs no packages from the registry.
Start with bun init in an empty directory. It scaffolds a package.json, a tsconfig.json and an entry file. The resulting package.json has no runtime dependencies. You may see @types/bun listed as a dev dependency, but it only provides editor type information and never runs in production. Bun executes TypeScript directly, so bun run index.ts starts the service with no compile step and no dist/ directory to keep in sync with the source.
The server API is small. You call Bun.serve({ port, fetch(req) }), and Bun invokes fetch once per incoming request. It receives a standard Web Request and must return a Response, or a promise that resolves to one. You read a JSON body with await req.json() and send one back with Response.json(data, { status }).
These are the same Request and Response types used by browsers, service workers and several other JavaScript runtimes. Handler logic written against them depends on the platform, not on one framework's context object. That has two practical effects:
- Your knowledge carries over to other environments.
- Moving the code later means replacing the server bootstrap, not rewriting every route.
// index.ts: run with `bun run index.ts` (no build step, no runtime dependencies)
const server = Bun.serve({
port: 3000,
// Bun calls fetch once per request with a standard Web Request.
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "GET" && url.pathname === "/health") {
return Response.json({ status: "ok" });
}
if (req.method === "POST" && url.pathname === "/echo") {
let body: unknown;
try {
// Parse the JSON body with the standard Request API.
body = await req.json();
} catch {
return Response.json({ error: "invalid JSON body" }, { status: 400 });
}
return Response.json({ received: body }, { status: 201 });
}
return Response.json({ error: "not found" }, { status: 404 });
},
});
console.log(`Listening on );
A minimal Bun.serve handler that routes on a standard Web Request, parses a JSON body with await req.json() and responds with Response.json(), using only built-in APIs.
How a request flows through the server and database
Every request follows the same path, and each stage has one job.
fetchhandler.Bun.servecalls a single function with a standardRequestand expects aResponse(or a promise of one) in return. There is no middleware chain; anything that should run on every request goes here.try/catchwrapper. The handler's body sits inside onetry/catch. Any exception thrown further down, such as a malformed JSON body or a constraint violation, ends up here and becomes a consistent JSON error response instead of an unhandled failure.- Routing. The handler parses
request.urlwithnew URL()and matches the method and pathname to a function. - Validation. The route handler checks the parsed body or path parameters before anything reaches the database. Bad input is rejected early with a 400.
- Prepared statements. Queries are prepared once at startup and reused for each request. This avoids re-parsing SQL and keeps parameter binding out of string concatenation. The database is a single file opened in WAL mode, so readers don't block on a writer at the SQLite level.
- JSON response. The result is serialized with
Response.json()and returned.
The detail that shapes everything later is that bun:sqlite is synchronous. The handler may be async because reading the request body is asynchronous, but each .get(), .all() or .run() call blocks the main thread until SQLite finishes. While a query runs, no other request on that process makes progress. For small, indexed queries this is fast and simple. A slow query, though, stalls the whole server. That trade-off is why concurrency gets its own discussion later.
Client
| HTTP Request
v
+----------------------------------------------+
| Bun.serve fetch(request) async, main thread |
| +------------------------------------------+ |
| | try { | |
| | Routing: new URL(), method + pathname | |
| | | | |
| | v | |
| | Validation --- bad input ---> 400 JSON | |
| | | | |
| | v | | +---------------------+
| | Prepared statement -- sync call --------------->| bun:sqlite |
| | .get() .all() .run() <-- rows ------------------| app.db (WAL mode) |
| | | (main thread blocked) | | | stmts prepared once |
| | v | | +---------------------+
| | Response.json(result) | |
| | } catch (err) -> JSON error response | |
| +------------------------------------------+ |
+----------------------------------------------+
|
v Response
Client
A request passes through the single fetch handler's try/catch, routing and validation, then reaches the WAL-mode SQLite file through a synchronous prepared-statement call that blocks the main thread, and leaves as a JSON response.
Implementing routing, persistence and CRUD for /items
Routing
Parse each request with new URL(req.url) and normalise the pathname by stripping one trailing slash, so /items/ and /items match the same route. Split the path into segments and pass the ID through decodeURIComponent. That call throws on malformed input such as %E0%A4, so catch the error and return 400. If the path matches no route, return 404. If the path matches but the method does not, return 405 with an Allow header listing the methods the path accepts. Some Bun versions also offer a declarative routes option on Bun.serve. Check the documentation for your version before you rely on it.
Persistence
Open the database once at startup with new Database(path). Then run PRAGMA journal_mode = WAL, which lets readers proceed while a write is in progress. Also run PRAGMA foreign_keys = ON, because SQLite does not enforce foreign keys unless each connection enables them. Create the schema with CREATE TABLE IF NOT EXISTS.
Prepare each statement once with db.query(), which caches the statement, or db.prepare(), which does not. Reuse the statement through .get(), .all() or .run(). Values are bound as parameters and never concatenated into the SQL string, so SQLite always treats them as data. A malicious value cannot change the structure of the query.
| Verb and path | SQL | Success | Missing row |
|---|---|---|---|
GET /items |
SELECT via .all() |
200 | n/a |
GET /items/:id |
SELECT via .get() |
200 | 404 |
POST /items |
INSERT via .run() |
201 + row | n/a |
PUT /items/:id |
UPDATE via .run() |
200 | 404 if changes is 0 |
DELETE /items/:id |
DELETE via .run() |
204 | 404 if changes is 0 |
For POST, read lastInsertRowid from the result of .run() and re-select the row before returning it.
When a write spans several statements, wrap them in db.transaction(fn). If anything inside fn throws, all of the writes roll back.
import { Database } from "bun:sqlite";
// Open once at startup; WAL lets reads run during writes, FK enforcement is per connection
const db = new Database("app.db");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA foreign_keys = ON");
db.run("CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)");
// Prepared once, reused per request; ? placeholders bind values as data, never as SQL
const listItems = db.query("SELECT id, name FROM items");
const getItem = db.query("SELECT id, name FROM items WHERE id = ?");
const insertItem = db.query("INSERT INTO items (name) VALUES (?)");
const deleteItem = db.query("DELETE FROM items WHERE id = ?");
// All inserts commit together or none do
const insertMany = db.transaction((names: string[]) => { for (const n of names) insertItem.run(n); });
const notFound = () => new Response("Not Found", { status: 404 });
const notAllowed = (allow: string) => new Response(null, { status: 405, headers: { Allow: allow } });
Bun.serve({
port: 3000,
async fetch(req) {
// filter(Boolean) drops empty segments, so /items/ and /items match the same route
const parts = new URL(req.url).pathname.split("/").filter(Boolean);
if (parts[0] !== "items" || parts.length > 2) return notFound();
if (parts.length === 1) {
if (req.method === "GET") return Response.json(listItems.all());
if (req.method !== "POST") return notAllowed("GET, POST");
const body = await req.json();
if (Array.isArray(body)) { insertMany(body); return new Response(null, { status: 201 }); }
const { lastInsertRowid } = insertItem.run(body.name);
return Response.json(getItem.get(lastInsertRowid), { status: 201 });
}
let id: string;
try { id = decodeURIComponent(parts[1]); } catch { return new Response("Bad Request", { status: 400 }); }
if (req.method === "GET") { const row = getItem.get(id); return row ? Response.json(row) : notFound(); }
if (req.method === "DELETE") return deleteItem.run(id).changes ? new Response(null, { status: 204 }) : notFound();
return notAllowed("GET, DELETE");
},
});
A minimal Bun.serve handler that routes /items by hand, uses statements prepared once with bound parameters, and inserts a batch inside db.transaction so the whole batch commits or none of it does.
Supplying what a framework would hide: validation, errors, migrations and shutdown
Validation
Bun.serve parses nothing for you, so every write handler has to check its input before it reaches SQLite. Reject requests whose Content-Type is not application/json with a 415. Wrap await req.json() in try/catch, because malformed JSON throws instead of returning null. Then check each field yourself: typeof name === "string", a trimmed length between 1 and 200, and no unexpected keys. Set maxRequestBodySize in the server options. Checking Content-Length alone is not enough, because clients can omit it or send a chunked body. Every rejection should return a 400 with the same shape, such as { error: { code, message } }, so clients can handle failures without matching on message text.
Errors
Anything you don't catch reaches the error handler in Bun.serve. Log the full error there and return a generic 500 with the same error shape. Never return the stack trace, because it exposes file paths and query text.
Migrations
CREATE TABLE IF NOT EXISTS only creates tables. It cannot add a column or an index to a table that already exists. Keep migrations in an ordered array instead. Read PRAGMA user_version, run each pending step inside db.transaction, and set the new version in the same transaction, so a failed step rolls back completely.
Configuration and shutdown
Read PORT and DB_PATH from process.env. Bun loads .env automatically, so this needs no package. On SIGINT and SIGTERM, call server.stop() and then db.close(). When the last connection closes cleanly, SQLite checkpoints the WAL file back into the main database.
import { Database } from "bun:sqlite";
// Bun loads .env automatically, so these come straight from process.env.
const db = new Database(process.env.DB_PATH ?? "app.db", { create: true });
db.exec("PRAGMA journal_mode = WAL");
// Append-only: never edit a migration that has already shipped.
const migrations = [
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
"ALTER TABLE items ADD COLUMN created_at TEXT",
"CREATE INDEX items_name ON items(name)",
];
const { user_version } = db.query("PRAGMA user_version").get() as { user_version: number };
const migrate = db.transaction((sql: string, version: number) => {
db.exec(sql);
// PRAGMA takes no bound parameters; version is a trusted integer.
db.exec(`PRAGMA user_version = ${version}`); // same transaction, so a failure rolls back both
});
for (let v = user_version; v < migrations.length; v++) migrate(migrations[v], v + 1);
const server = Bun.serve({
port: Number(process.env.PORT ?? 3000),
maxRequestBodySize: 64 * 1024, // enforced even when Content-Length is missing or the body is chunked
fetch(req) {
return Response.json({ error: { code: "not_found", message: "Not found" } }, { status: 404 });
},
error(err) {
console.error(err); // full detail goes to the logs only
return Response.json({ error: { code: "internal", message: "Internal server error" } }, { status: 500 });
},
});
async function shutdown() {
await server.stop(); // stop accepting new connections
db.close(); // closing the last connection checkpoints the WAL
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
This example shows a versioned migration runner built on PRAGMA user_version and db.transaction, a body size limit, a 500 error handler that does not expose stack traces, configuration read from environment variables, and a signal-driven shutdown that stops the server before closing the database.
Testing the API with bun test
The shutdown path matters in tests too, because every test starts and stops its own server.
Make the app a factory
Testing is easiest when the module that calls Bun.serve has no side effects at import time. Export a function such as createApp({ db, port }) that runs migrations against the database it receives, starts the server, and returns both. The production entry point opens a file-backed database and reads the port from the environment. Tests pass new Database(":memory:") and port: 0. With port 0, the operating system assigns a free ephemeral port, which you read back from server.port or server.url. Parallel runs and leftover processes then can't collide on a fixed port, and each test gets an empty schema with no cleanup scripts. If you need to test WAL behaviour or file locking, use a temp-file path instead, because an in-memory database doesn't exercise those code paths.
Write the tests with fetch
In bun:test, create the app in beforeEach. In afterEach, call server.stop(true) to force-close open connections and then db.close(). If you skip either call, the runner can hang or leak handles between tests.
Call endpoints with the global fetch against server.url. Assert on res.status before you parse the body, so a failing test reports the wrong status rather than a JSON parse error. Cover the error paths as well as the happy path:
- a malformed or invalid POST body returns
400with the error shape your validator defines - a GET for a missing id returns
404 - a
PATCHto/itemsreturns405, and theAllowheader lists the permitted methods
When to use this approach and when to avoid it
Those checks prove the code works. Whether the design fits your service is a separate question. bun:sqlite calls are synchronous and run on the same thread that handles requests. A slow query does not only delay its own response. It stalls every other request until the query returns. SQLite also allows only one writer at a time, so concurrent writes queue behind each other, even in WAL mode.
| Factor | Good fit | Avoid |
|---|---|---|
| Workload | Small internal services, indexed lookups | Heavy analytical queries, large batch imports |
| Writes | Low to moderate volume | Write-heavy traffic serializing behind one writer |
| Processes | One process owns the file | Several processes sharing the file without a busy timeout |
| Hosting | Single long-lived host with persistent disk | Ephemeral containers, serverless without durable storage |
| Availability | Downtime during restarts is acceptable | Horizontal scaling or automatic failover required |
If several processes must open the same file, set busy_timeout. Without it, a process that finds the database locked fails immediately with SQLITE_BUSY instead of waiting for the lock. On ephemeral platforms, the database disappears when the instance is replaced. Scaling out gives you separate databases that never sync with each other.
Consider lock-in too. Code using bun:sqlite and Bun.serve runs only on Bun. Handlers written against the standard Request and Response objects move more easily to other runtimes, so keep business logic in those handlers and isolate the database layer. Bun's APIs have changed between releases, so pin the Bun version in CI and in your deployment image, and upgrade on purpose.
Alternatives and the impact on cost and engineering time
Each alternative trades a cost you pay once, or never, for one you pay on every upgrade.
| Alternative | What you gain | What you take on |
|---|---|---|
| Lightweight router or framework on Bun | Route params, middleware chains, CORS and logging helpers | Dependency upgrades, supply-chain review, framework conventions |
| Query builder or ORM with a migration tool | Typed queries, generated migrations, rollback support | Often a build or codegen step, and an abstraction over SQL you already control |
| Client-server database such as PostgreSQL | Concurrent writers, multiple app instances, managed backups | A network hop, connection pooling, and a service to run and pay for |
| Same design on Node with its own SQLite driver | Mature runtime, broader hosting support | A driver package (sometimes native), plus separate test tooling |
The zero-dependency version has no node_modules to audit, no build step, fast startup, and bun test with no extra tooling. The cost appears as the API grows. CORS, auth, rate limiting, logging, request IDs and OpenAPI docs all become code you write and maintain. Your switch statement slowly turns into a mini-framework that only you document.
Illustrative estimate (assumed numbers, not a benchmark): hand-rolled cost per year = features × build hours + features × upkeep hours. framework cost per year = upgrades per year × review hours. With 6 features × 4 h + 6 × 2 h = 36 h, against 4 upgrades × 3 h = 12 h, the framework is cheaper. Plug in your own numbers.
A practical signal: adopt a framework once you need the same middleware on most routes, or once you pass roughly 10 to 15 routes. That route range is also an illustrative threshold, not a measured one.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to bun.serve api, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.