
Hono + SQLite: The Stupidly Simple API Stack That's Taking Over
April 11, 2026
A hono sqlite api handles 50K+ requests per second on a single core. No connection pooling, no ORM config files, no Docker Compose. This post builds a complete REST API in under 100 lines using Hono, Drizzle, and SQLite on Bun.
Why This Stack#
Express handles roughly 15K req/s on Node.js. Fastify pushes that to 50K. Hono on Bun hits 130K+ for the same JSON response handler, with a 14KB bundle and zero dependencies.
SQLite is the other half. It runs in-process, so there is no TCP round-trip to a database server. Reads hit 100K queries/second on modest hardware. Writes in WAL mode sustain 70K/s. For a single-server API, this removes an entire infrastructure layer.
Requests/sec on Node.js (simple JSON response)
The pitch is not raw speed alone. It is the removal of moving parts. No Postgres container, no connection string, no pool size tuning, no docker-compose.yml. The database is a single file that you can copy with scp.
- Zero network latency on reads. SQLite runs in the same process.
- Single-file backups. Copy
data.dbto S3 and you are done. - No cold-start penalty. Hono's
hono/tinypreset is under 12KB gzipped. - WAL mode handles concurrent readers without locks.
- Drizzle gives you typed queries without the config overhead of Prisma.
Project Setup#
Start with Bun. If you have not used it before, I covered the runtime's less obvious features in a separate post.
bun init hono-api
cd hono-api
bun add hono drizzle-orm
bun add -D drizzle-kit
That is four dependencies total. Two runtime, two dev. Compare that to a typical Express + Prisma + PostgreSQL setup where node_modules pulls in 200+ packages before you write a line of code.
Schema and Database#
Drizzle's SQLite adapter uses Bun's built-in bun:sqlite driver. No native bindings, no node-gyp, no compilation step. The schema definition is 10 lines.
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
body: text("body").notNull(),
authorId: integer("author_id").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.$defaultFn(() => new Date()),
});
Now wire up the database connection. Drizzle wraps bun:sqlite and gives you a typed query builder.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import * as schema from "./schema";
const sqlite = new Database("data.db");
sqlite.exec("PRAGMA journal_mode = WAL");
sqlite.exec("PRAGMA synchronous = normal");
export const db = drizzle(sqlite, { schema });
Tip: The two PRAGMA statements are critical. WAL mode allows concurrent reads during writes. synchronous = normal reduces fsync calls by 10x while still protecting against corruption on power loss.Generate and run migrations with Drizzle Kit.
bunx drizzle-kit generate --dialect sqlite --schema src/db/schema.ts
bunx drizzle-kit migrate
Routes and Handlers#
This is where Hono shines. The routing API is Express-like but typed end-to-end. The entire CRUD surface for a posts resource fits in one file.
import { Hono } from "hono";
import { db } from "./db";
import { posts } from "./db/schema";
import { eq } from "drizzle-orm";
const app = new Hono();
// List all posts
app.get("/posts", async (c) => {
const all = await db.select().from(posts);
return c.json(all);
});
// Get single post
app.get("/posts/:id", async (c) => {
const id = Number(c.req.param("id"));
const [post] = await db.select().from(posts).where(eq(posts.id, id));
if (!post) return c.json({ error: "not found" }, 404);
return c.json(post);
});
// Create post
app.post("/posts", async (c) => {
const body = await c.req.json();
const [created] = await db.insert(posts).values(body).returning();
return c.json(created, 201);
});
// Update post
app.put("/posts/:id", async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const [updated] = await db
.update(posts)
.set(body)
.where(eq(posts.id, id))
.returning();
if (!updated) return c.json({ error: "not found" }, 404);
return c.json(updated);
});
// Delete post
app.delete("/posts/:id", async (c) => {
const id = Number(c.req.param("id"));
await db.delete(posts).where(eq(posts.id, id));
return c.body(null, 204);
});
export default app;
Count the lines. The schema is 10. The database setup is 8. The routes are about 40. Under 60 lines of actual logic for a typed, validated CRUD API with a real database.
Run it with bun run src/index.ts. Bun detects the export default and starts an HTTP server on port 3000 automatically. No app.listen() call needed.
Adding Validation#
Hono has a built-in Zod validator middleware. This keeps request validation co-located with the route instead of buried in a separate layer.
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
authorId: z.number().int().positive(),
});
app.post(
"/posts",
zValidator("json", createPostSchema),
async (c) => {
const body = c.req.valid("json");
const [created] = await db.insert(posts).values(body).returning();
return c.json(created, 201);
}
);
Invalid requests get a 400 with field-level errors before your handler runs. The c.req.valid("json") call returns a fully typed object matching the Zod schema.
Deploy Options#
The same Hono app runs on multiple runtimes without code changes. That is the framework's core design principle. Your deploy target changes the entry point, not the business logic.
Bun Server#
The fastest option. export default app is all Bun needs. For production, compile it to a single binary.
bun build --compile src/index.ts --outfile api-server
./api-server
That binary includes the Bun runtime. Ship it to any Linux machine and run it. No Node, no Bun, no npm install on the server. I covered the full build-to-binary workflow in a separate post.
Cloudflare Workers#
Hono was originally built for Workers. Swap SQLite for Cloudflare D1 (which is SQLite under the hood) and deploy to the edge.
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./db/schema";
type Bindings = { DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
app.get("/posts", async (c) => {
const db = drizzle(c.env.DB, { schema });
const all = await db.select().from(schema.posts);
return c.json(all);
});
export default app;
The route handlers stay identical. Only the database initialization changes from bun:sqlite to a D1 binding.
Docker#
If you need containers, the Dockerfile is minimal. Bun's official image is 130MB compared to Node's 350MB+.
FROM oven/bun:1 AS base
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
COPY src ./src
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]
Mount a volume for data.db so the database survives container restarts. That is the only state you need to persist.
When SQLite Is Not Enough#
SQLite has real limits. It handles one writer at a time. If your API processes more than a few hundred write-heavy requests per second, you will hit lock contention. Multiple server instances cannot share the same SQLite file over a network filesystem.
- Outgrown SQLite: migrate to Postgres with Drizzle. Change the dialect, update the connection, keep all your queries.
- Need horizontal scaling: move to D1 or Turso (distributed SQLite). Same SQL, replicated reads.
- Write-heavy workloads: batch inserts inside transactions. SQLite handles 70K writes/s when batched vs 100/s with individual autocommit.
The migration path is the point. Drizzle abstracts the dialect, so switching from SQLite to Postgres means changing one import and one config object. Your schema and queries stay the same.
TL;DR: Hono + Drizzle + SQLite gives you a typed REST API in under 100 lines. It handles 50K+ req/s, deploys as a single binary, and migrates to Postgres when you outgrow it. Start here, scale later.