ElysiaJS: Ultra-Fast TypeScript API Framework for Bun
ElysiaJS is the newest kid on the block for building TypeScript‑first APIs, and it runs on Bun – the ultra‑fast JavaScript runtime that’s shaking up the Node ecosystem. If you’ve ever felt the latency of traditional Node servers, you’ll appreciate how Elysia leverages Bun’s native HTTP handling, JIT‑compiled TypeScript, and zero‑dependency design to shave off precious milliseconds. In this article we’ll dive deep into the framework, walk through real‑world examples, and share pro tips that help you squeeze every drop of performance out of Bun.
Why Bun Matters
Bun is not just another Node alternative; it’s a complete rewrite in Zig that bundles a JavaScript engine, a package manager, and a test runner into a single binary. The runtime ships with an optimized HTTP server that bypasses the libuv event loop, resulting in dramatically lower overhead for I/O‑bound workloads. Because Bun compiles TypeScript on the fly, you can skip the tsc step and enjoy near‑instant reloads during development. This speed advantage is the foundation on which Elysia builds its own performance claims.
From a developer’s perspective, Bun’s bun install is lightning‑fast, and its built‑in bundler produces smaller output files compared to Webpack or esbuild. The runtime also supports native Web APIs like fetch, FormData, and AbortController, meaning you can write code that feels like you’re targeting the browser, but it runs on the server.
Introducing ElysiaJS
ElysiaJS is a minimalist, type‑safe API framework designed specifically for Bun. It embraces a functional style where routes, middleware, and plugins are plain functions that compose together without hidden magic. The core library is under 5 KB gzipped, and every feature—validation, schema inference, and even dependency injection—is optional and pluggable.
What sets Elysia apart from Express, Fastify, or even NestJS is its tight integration with TypeScript’s type system. When you define a route, the request and response objects are automatically typed based on your schemas, giving you IntelliSense and compile‑time safety without extra decorators or runtime reflection.
Getting Started in 5 Minutes
First, install Bun (if you haven’t already) and bootstrap a new project:
curl -fsSL https://bun.sh/install | bash
mkdir my-elysia-app && cd my-elysia-app
bun init -y
bun add elysia
Now create src/server.ts with a minimal “Hello, World!” endpoint:
import { Elysia } from "elysia";
new Elysia()
.get("/", () => "👋 Hello from Elysia on Bun!")
.listen(3000);
console.log("🚀 Server running at http://localhost:3000");
Run the server with bun run src/server.ts. You’ll see the console log instantly, and a curl request returns the greeting in under 1 ms on a typical laptop. That’s the baseline; everything else builds on this tiny core.
Routing Made Type‑Safe
Elysia’s routing API mirrors the HTTP verbs you know, but each handler receives a typed Context object. Let’s add a dynamic route that extracts a user ID from the URL and returns a typed JSON payload.
import { Elysia, t } from "elysia";
type User = {
id: string;
name: string;
email: string;
};
const users: Record = {
"1": { id: "1", name: "Ada Lovelace", email: "ada@example.com" },
"2": { id: "2", name: "Alan Turing", email: "alan@example.com" },
};
new Elysia()
.get(
"/users/:id",
({ params }: { params: { id: string } }) => {
const user = users[params.id];
if (!user) return { status: 404, body: { error: "User not found" } };
return { status: 200, body: user };
},
{
// Schema for automatic type inference
params: t.Object({ id: t.String() }),
response: {
200: t.Object({
id: t.String(),
name: t.String(),
email: t.String(),
}),
404: t.Object({ error: t.String() }),
},
}
)
.listen(3000);
The t.Object schema not only validates incoming data at runtime but also informs TypeScript that params.id is a string and the response shape is known. If you try to return a field that isn’t defined in the schema, the compiler will raise an error.
Built‑In Validation & Sanitization
Elysia ships with a lightweight validation engine powered by valibot. You can validate query strings, request bodies, and even headers with a single line of code. Here’s a CRUD example that validates a POST payload before creating a new user.
import { Elysia, t } from "elysia";
new Elysia()
.post(
"/users",
({ body }: { body: { name: string; email: string } }) => {
const id = crypto.randomUUID();
users[id] = { id, ...body };
return { status: 201, body: users[id] };
},
{
body: t.Object({
name: t.String({ minLength: 2 }),
email: t.String({ format: "email" }),
}),
response: {
201: t.Object({
id: t.String(),
name: t.String(),
email: t.String(),
}),
},
}
)
.listen(3000);
When a client sends malformed JSON—say, an email without an “@” sign—the framework automatically returns a 400 response with a clear validation error. No extra middleware required.
Middleware That Doesn’t Slow You Down
Elysia’s middleware follows the same functional pattern as routes: a function that receives Context and either returns a response or calls next(). Because Bun’s HTTP server already streams data efficiently, the middleware layer adds virtually no overhead.
Below is a simple logger that records request method, path, and response time. Notice the use of await next() to measure the exact duration of downstream handlers.
import { Elysia } from "elysia";
const logger = async ({ request, set }, next) => {
const start = Date.now();
const response = await next();
const ms = Date.now() - start;
console.log(
`${request.method} ${request.url} → ${response.status} (${ms}ms)`
);
return response;
};
new Elysia()
.use(logger)
.get("/", () => "Logged!")
.listen(3000);
Pro tip: Place heavy‑weight middleware (e.g., authentication) early in the chain to short‑circuit requests before they hit expensive business logic.
Plugins: Extend the Core Without Bloat
Plugins in Elysia are first‑class citizens. They let you encapsulate reusable functionality—like JWT authentication, CORS handling, or rate limiting—while keeping the core bundle lean. A plugin is simply a function that receives the app instance and returns it, optionally augmenting the type definitions.
Here’s a minimalist JWT plugin that adds a user property to the context when a valid token is present.
import { Elysia, type Context } from "elysia";
import { verify } from "jsonwebtoken";
type JwtPayload = { sub: string; role: string };
function jwtPlugin(app: Elysia) {
return app.use(async ({ request, set }: Context, next) => {
const auth = request.headers.get("Authorization");
if (!auth?.startsWith("Bearer ")) return await next();
const token = auth.split(" ")[1];
try {
const payload = verify(token, process.env.JWT_SECRET) as JwtPayload;
// Attach user info to the context for downstream handlers
set("user", payload);
} catch {
// Invalid token – continue without user info
}
return await next();
});
}
// Usage
new Elysia()
.use(jwtPlugin)
.get("/me", ({ set }) => {
const user = set("user");
if (!user) return { status: 401, body: { error: "Unauthenticated" } };
return { status: 200, body: { id: user.sub, role: user.role } };
})
.listen(3000);
Because the plugin augments the set method, TypeScript now knows that set("user") returns a JwtPayload | undefined, giving you autocomplete inside every route that depends on authentication.
Performance Benchmarks: Numbers That Speak
Benchmarks from the official Elysia repo show that a simple “Hello World” endpoint on Bun handles ~2.2 M requests per second (RPS) on a 12‑core machine, compared to ~1.1 M RPS for Fastify on Node 20. When you add validation and middleware, the drop is less than 10 %, still well above 1.8 M RPS.
Real‑world tests on a CRUD microservice (MongoDB‑backed) demonstrate a 30 % latency reduction versus an equivalent Express implementation, mainly because Bun avoids the overhead of the libuv thread pool and Elysia’s zero‑dependency validation runs in native JavaScript.
Real‑World Use Cases
Microservices Architecture
In a microservices environment, each service needs to be lightweight and start quickly. Elysia’s small footprint (<5 KB) means you can spin up dozens of containers without bloating your Docker images. Combined with Bun’s fast start‑up time (<50 ms), cold‑start latency in Kubernetes becomes negligible.
Serverless Functions
Platforms like Vercel and Cloudflare Workers now support Bun as a runtime. Deploying an Elysia API as a serverless function yields sub‑100 ms cold starts, which is ideal for edge‑computing scenarios where latency is king. The type‑safe schemas also reduce runtime errors that would otherwise cause costly retries.
Edge APIs & CDN Integration
Because Bun can compile to a single binary, you can bundle an Elysia app and serve it directly from a CDN edge node. This pattern is perfect for authentication gateways, feature‑flag services, or A/B testing endpoints that need to be globally distributed.
Pro Tips for Maximum Throughput
💡 Enable Bun’s --hot flag during development to get instant reloads without restarting the process. It watches your source files and recompiles only the changed modules.
💡 Use app.group() to namespace routes and apply group‑level middleware. This reduces duplicate middleware calls and keeps your codebase organized.
💡 For CPU‑bound tasks (e.g., image processing), offload work to a worker pool using Bun’s Worker API. Keep the main event loop free for I/O to maintain that sub‑millisecond latency.
Comparison with Popular Frameworks
Compared to Express, Elysia eliminates the need for external body‑parsing, validation, and routing libraries. Express relies on a large middleware ecosystem, which adds both bundle size and runtime overhead. Fastify is faster than Express but still runs on Node’s libuv loop, which introduces a baseline latency that Bun sidesteps entirely.
When you factor in developer experience, Elysia’s built‑in TypeScript inference beats the decorator‑heavy approach of NestJS. You get full type safety without sacrificing runtime performance, and you avoid the extra compilation step that Nest requires.
Advanced Patterns: Dependency Injection & Scoped Services
Elysia doesn’t ship a full DI container, but its set / get API allows you to create scoped services per request. This is useful for database connections, request‑specific caches, or feature flags.
import { Elysia } from "elysia";
import { PrismaClient } from "@prisma/client";
function prismaPlugin(app: Elysia) {
const prisma = new PrismaClient();
return app
.onBeforeHandle(({ set }) => set("prisma", prisma))
.onAfterHandle(() => prisma.$disconnect());
}
new Elysia()
.use(prismaPlugin)
.get("/posts", async ({ get }) => {
const prisma = get("prisma");
const posts = await prisma.post.findMany();
return { status: 200, body: posts };
})
.listen(3000);
The onBeforeHandle hook injects a shared Prisma client into each request’s context, while onAfterHandle ensures graceful shutdown. Because the client lives outside the request loop, you avoid the overhead of re‑instantiating it for every call.
Testing Elysia Apps with Bun’s Built‑In Test Runner
Bun includes a fast test runner that works out of the box with Elysia. Write tests in TypeScript, import the app instance, and use fetch to hit the in‑memory server.
import { test, expect } from "bun:test";
import { Elysia } from "elysia";
const app = new Elysia()
.get("/ping", () => "pong")
.listen(0); // 0 picks a random free port
test("GET /ping returns pong", async () => {
const res = await fetch(`http://localhost:${app.port}/ping`);
const text = await res.text();
expect(res.status).toBe(200);
expect(text).toBe("pong");
});
app.stop(); // Clean up after tests
Running bun test executes the suite in parallel, and because Bun’s runtime is already loaded, the startup cost is negligible. This makes TDD a smooth experience even for large APIs.
Deploying to Production
Deploying an Elysia app is as simple as copying the compiled binary and the node_modules folder to your server. Since Bun bundles the runtime, you don’t need a separate Node installation. A typical Dockerfile looks like this:
FROM oven/bun:latest
WORKDIR /app
COPY . .
RUN bun install --production
EXPOSE 3000
CMD ["bun", "run", "src/server.ts"]
For serverless platforms that support custom runtimes, you can zip the entire project and point the handler to src/server.ts. The small bundle size (<10 MB) ensures quick uploads and fast cold starts.
Monitoring & Observability
Elysia emits events for request start, finish, and error. You can hook these into Prometheus, Grafana, or any logging service. Here’s a quick integration with bunyan for structured logs:
import { Elysia } from "elysia";
import bunyan from "bunyan";
const logger = bunyan.createLogger