Skip to content
Dev Tools Beginner Tutorial

Build a Blazing-Fast REST API with Bun and Elysia.js

Ship a typed CRUD API on Bun's native SQLite driver and Elysia, then benchmark it against Express to see the actual difference.

Mariana Souza
Mariana Souza
Senior Editor · Jul 16, 2026 · 9 min read

What you'll build

A typed CRUD API for a todo list, running on Bun's native HTTP server with Elysia as the framework and bun:sqlite as the database, no external drivers or ORMs. You'll then benchmark it against a minimal Express server to see where Bun actually pulls ahead.

Prerequisites

  • macOS, Linux, or WSL2 on Windows (Bun's native Windows support is still catching up, use WSL2 there)
  • Bun v1.1.0 or later (bun --version to check)
  • Node.js 18+ only needed for the Express comparison
  • Basic familiarity with TypeScript and REST concepts
  • curl for testing endpoints

1. Install Bun and scaffold the project

If you don't have Bun yet:

curl -fsSL https://bun.sh/install | bash

This is the official installer, it just drops a binary in ~/.bun/bin and updates your shell profile. No sudo required. Restart your terminal or source your profile, then confirm:

bun --version

Scaffold the project:

mkdir bun-elysia-api && cd bun-elysia-api
bun init -y
bun add elysia

bun init -y accepts the defaults, creating index.ts, package.json, and tsconfig.json, and it installs @types/bun as a dev dependency so TypeScript understands bun:sqlite and the global Bun object out of the box.

2. Set up the database

bun:sqlite is built into the Bun runtime, no npm install needed. Open index.ts, delete the placeholder content, and put both imports you'll need for this whole file at the very top, Database now and Elysia for the routes you'll add in the next step. Keeping imports at the top (instead of scattering them mid-file) avoids linter complaints and just reads better:

import { Database } from "bun:sqlite";
import { Elysia, t } from "elysia";

const db = new Database("todos.sqlite");

db.exec(`
  CREATE TABLE IF NOT EXISTS todos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    completed INTEGER DEFAULT 0
  )
`);

Note it's db.exec(...), not db.run(...). The Database class in bun:sqlite doesn't expose a .run() method, that one only exists on prepared Statement objects (you'll use it below for parameterized inserts and updates). .exec() is the correct call for one-off, non-parameterized schema statements like this CREATE TABLE.

This creates a file-backed SQLite database on first run. bun:sqlite is synchronous and doesn't need await, which is part of why it's fast, no async overhead for a local file.

3. Build the CRUD routes with Elysia

Now add the routes below the database initialization, in the same file. Both imports are already in place from Step 2, so nothing needs to move:

const app = new Elysia()
  .get("/todos", () => db.query("SELECT * FROM todos").all())
  .get("/todos/:id", ({ params, set }) => {
    const todo = db.query("SELECT * FROM todos WHERE id = ?").get(params.id);
    if (!todo) {
      set.status = 404;
      return { error: "Not found" };
    }
    return todo;
  })
  .post(
    "/todos",
    ({ body }) =>
      db.query("INSERT INTO todos (title) VALUES (?) RETURNING *").get(body.title),
    { body: t.Object({ title: t.String() }) }
  )
  .put(
    "/todos/:id",
    ({ params, body }) => {
      db.query("UPDATE todos SET completed = ? WHERE id = ?").run(
        body.completed ? 1 : 0,
        params.id
      );
      return db.query("SELECT * FROM todos WHERE id = ?").get(params.id);
    },
    { body: t.Object({ completed: t.Boolean() }) }
  )
  .delete("/todos/:id", ({ params }) => {
    db.query("DELETE FROM todos WHERE id = ?").run(params.id);
    return { success: true };
  })
  .listen(3000);

console.log("\ud83e\udd8a Elysia is running at http://localhost:3000");

The t.Object(...) blocks are TypeBox schemas. Elysia validates incoming request bodies against them at runtime and infers the TypeScript type for body in the handler, so you get real type safety without writing separate interfaces or manual validation.

4. Run it

bun --watch index.ts

--watch restarts the server on file changes, similar to nodemon. You should see the log line above almost instantly, Bun's startup time is one of its biggest practical wins over Node. Leave this terminal running, you'll need the server up for the next step.

5. Benchmark against Express

Open a new terminal window or tab before you do anything else here. The Bun server from Step 4 has to keep running in its own terminal, and Express will need one too once it's up, so you don't want everything fighting over one blocked shell.

In the new terminal, spin up a bare-minimum Express equivalent for comparison, just enough to be fair:

mkdir ../express-api && cd ../express-api
npm init -y
npm install express
// index.js
const express = require("express");
const app = express();
app.use(express.json());

const todos = [];
app.get("/todos", (req, res) => res.json(todos));

app.listen(3001, () => console.log("Express running on http://localhost:3001"));
node index.js

This also blocks its terminal, so open a third terminal tab for the actual benchmark commands. Now hit both servers with autocannon (no global install needed, bunx runs it on the fly):

bunx autocannon -c 100 -d 10 http://localhost:3000/todos
bunx autocannon -c 100 -d 10 http://localhost:3001/todos

On a typical dev laptop, expect Bun/Elysia to post noticeably higher requests/sec and lower latency, often 1.5x to 3x, since Bun's HTTP server is written on top of uSockets (the same C library behind uWebSockets) instead of Node's HTTP stack. That gap shrinks once you add real database I/O, network calls, or heavier middleware, so treat this as a ceiling, not a guarantee for your production workload.

Verify it works

curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Elysia"}'

Expect: {"id":1,"title":"Learn Elysia","completed":0}

curl http://localhost:3000/todos

Expect a JSON array containing that todo. Try PUT /todos/1 with {"completed":true} and confirm the value flips.

Troubleshooting

  • Cannot find module 'elysia': you skipped bun add elysia, or you're running with node instead of bun. Run bun install in the project root.
  • SqliteError: no such table: todos: the schema db.exec(...) call runs after you already tried a query, or you pointed at a different .sqlite file path. Delete todos.sqlite and restart.
  • Error: Failed to start server. Is port 3000 in use?: something else is bound to that port. Find it with lsof -i :3000 (macOS/Linux) and kill it, or change .listen(3000) to another port.
  • CORS errors calling from a browser: Elysia doesn't enable CORS by default. Add bun add @elysiajs/cors and chain .use(cors()) before your routes.

Next steps

Add @elysiajs/swagger for auto-generated OpenAPI docs, and @elysiajs/cors if you're calling this from a frontend. Look at Elysia's plugin system for auth (@elysiajs/jwt) and try swapping bun:sqlite for a real Postgres instance via postgres.js once you outgrow a single file. Bun deploys cleanly to Railway, Fly.io, or a plain Docker container using the official oven/bun image.

Mariana Souza
Written by
Mariana Souza · Senior Editor

Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading