Backend Framework Comparison: Express vs Fastify vs Hono vs Elysia
Backend Framework Comparison: Express vs Fastify vs Hono vs Elysia
The JavaScript backend framework landscape has fragmented. Express dominated for a decade, but newer frameworks offer significantly better performance, TypeScript support, and developer experience. Choosing the right framework depends on your runtime, your team's experience, and whether you need a massive ecosystem or cutting-edge performance.
Framework Overview
| Feature | Express | Fastify | Hono | Elysia |
|---|---|---|---|---|
| Runtime | Node.js | Node.js | Multi-runtime | Bun |
| Language | JavaScript | JavaScript/TypeScript | TypeScript | TypeScript |
| Type safety | Minimal | Good | Excellent | Excellent |
| Middleware model | Linear chain | Plugin-based (encapsulated) | Compose-based | Plugin-based |
| Validation | Manual / third-party | Built-in (Ajv) | Built-in (Zod optional) | Built-in (TypeBox) |
| OpenAPI generation | Manual | Plugin available | Built-in | Built-in |
| Performance (req/s) | ~15,000 | ~50,000 | ~70,000 | ~90,000+ |
| First release | 2010 | 2016 | 2022 | 2022 |
| npm weekly downloads | ~30M | ~4M | ~500K | ~100K |
Performance numbers are approximate and vary by benchmark methodology. The trend matters more than the exact numbers.
Express: The Establishment
Express is the most widely used Node.js framework. Its strength is ecosystem size -- virtually every Node.js tutorial, middleware, and integration library works with Express. Its weakness is everything else: poor TypeScript support, no built-in validation, slow performance, and a middleware model that makes errors hard to trace.
Basic Express App
import express from "express";
const app = express();
app.use(express.json());
// Middleware -- runs for every request
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});
// Route
app.get("/users/:id", async (req, res) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
} catch (error) {
res.status(500).json({ error: "Internal server error" });
}
});
// Error handler (must have 4 parameters)
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: "Something went wrong" });
});
app.listen(3000);
Express Pain Points
Poor TypeScript experience: Request params, query, and body are all any by default. You need manual type assertions or wrappers:
// Without type safety -- req.params.id is string | undefined at runtime but `any` in types
app.get("/users/:id", (req, res) => {
const id = req.params.id; // any
const limit = req.query.limit; // any
});
// With manual typing -- verbose and error-prone
interface UserParams {
id: string;
}
app.get("/users/:id", (req: Request<UserParams>, res) => {
const id = req.params.id; // string -- better, but not validated
});
No input validation: You need a separate library (Joi, Zod, express-validator) and manual wiring:
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post("/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
// result.data is typed correctly
createUser(result.data);
});
When to Choose Express
- You're maintaining an existing Express app -- migration cost often isn't worth it
- Your team is entirely new to backend development -- Express has the most learning resources
- You need a specific middleware that only exists for Express
- You're building a quick prototype that won't need to scale
Fastify: The Performance Upgrade for Node.js
Fastify is what Express should have been. It's 3-4x faster, has built-in validation with JSON Schema, a proper plugin system with encapsulation, and good TypeScript support. If you're on Node.js and want better than Express, this is the default choice.
Basic Fastify App
import Fastify from "fastify";
const app = Fastify({ logger: true });
// Plugin system -- encapsulated contexts
app.register(
async (instance) => {
// This decorator is only available within this plugin
instance.decorate("db", createDbConnection());
instance.get("/users/:id", {
schema: {
params: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
},
required: ["id"],
},
response: {
200: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
email: { type: "string" },
},
},
},
},
handler: async (request, reply) => {
const { id } = request.params as { id: string };
const user = await instance.db.users.findById(id);
if (!user) return reply.status(404).send({ error: "Not found" });
return user;
},
});
},
{ prefix: "/api/v1" }
);
app.listen({ port: 3000 });
Type-Safe Fastify with TypeBox
import { Type, Static } from "@sinclair/typebox";
const UserSchema = Type.Object({
id: Type.String({ format: "uuid" }),
name: Type.String({ minLength: 1 }),
email: Type.String({ format: "email" }),
createdAt: Type.String({ format: "date-time" }),
});
type User = Static<typeof UserSchema>;
const CreateUserSchema = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
email: Type.String({ format: "email" }),
});
app.post("/users", {
schema: {
body: CreateUserSchema,
response: { 201: UserSchema },
},
handler: async (request, reply) => {
// request.body is fully typed as { name: string; email: string }
const user = await createUser(request.body);
reply.status(201).send(user);
},
});
Fastify Plugin System
Fastify's plugin system is its best feature. Plugins are encapsulated -- decorators and hooks registered in a plugin don't leak to sibling plugins:
// plugins/auth.ts
import fp from "fastify-plugin";
export default fp(async (fastify) => {
fastify.decorate("authenticate", async (request: FastifyRequest) => {
const token = request.headers.authorization?.replace("Bearer ", "");
if (!token) throw fastify.httpErrors.unauthorized("Missing token");
const user = await verifyJwt(token);
request.user = user;
});
fastify.addHook("preHandler", async (request) => {
if (request.routeOptions.config.requireAuth) {
await fastify.authenticate(request);
}
});
});
// Usage in routes
app.get(
"/profile",
{ config: { requireAuth: true } },
async (request) => {
return request.user;
}
);
When to Choose Fastify
- You're on Node.js and want better performance than Express
- You want built-in validation without bolting on a separate library
- You need a mature plugin ecosystem (auth, rate limiting, CORS, etc.)
- Your team values JSON Schema for API contracts and documentation
- You're building a production API that needs to handle real traffic
Hono: The Multi-Runtime Framework
Hono is designed for the edge and multi-runtime world. The same code runs on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge Functions. If your deployment target isn't just Node.js, Hono is the strongest choice.
Basic Hono App
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { validator } from "hono/validator";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
const app = new Hono();
// Built-in middleware
app.use("*", logger());
app.use("*", cors());
// Route with Zod validation
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post("/users", zValidator("json", createUserSchema), async (c) => {
const body = c.req.valid("json"); // Fully typed: { name: string; email: string }
const user = await createUser(body);
return c.json(user, 201);
});
// Route groups
const api = new Hono();
api.get("/users", async (c) => {
const users = await listUsers();
return c.json(users);
});
api.get("/users/:id", async (c) => {
const id = c.req.param("id");
const user = await getUser(id);
if (!user) return c.json({ error: "Not found" }, 404);
return c.json(user);
});
app.route("/api", api);
export default app;
Hono's RPC Feature
Hono has a unique RPC feature that lets you share types between your API and client without code generation:
// server.ts
import { Hono } from "hono";
const app = new Hono()
.get("/users", async (c) => {
const users = await db.users.findMany();
return c.json(users);
})
.post(
"/users",
zValidator("json", createUserSchema),
async (c) => {
const body = c.req.valid("json");
const user = await createUser(body);
return c.json(user, 201);
}
);
export type AppType = typeof app;
// client.ts -- in your frontend or another service
import { hc } from "hono/client";
import type { AppType } from "../server";
const client = hc<AppType>("http://localhost:3000");
// Fully typed -- TypeScript knows the response shape
const response = await client.users.$get();
const users = await response.json(); // User[] -- inferred from server types
const newUser = await client.users.$post({
json: { name: "Alice", email: "[email protected]" },
});
Deploy Anywhere
// Cloudflare Workers
export default app;
// Node.js
import { serve } from "@hono/node-server";
serve(app, { port: 3000 });
// Bun
export default { port: 3000, fetch: app.fetch };
// Deno
Deno.serve(app.fetch);
// AWS Lambda
import { handle } from "hono/aws-lambda";
export const handler = handle(app);
When to Choose Hono
- You're deploying to edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy)
- You want runtime portability -- same code on multiple platforms
- You want end-to-end type safety without code generation (Hono RPC)
- You value a small footprint -- Hono is ~14KB with zero dependencies
- You're building APIs for Bun and want something lighter than Elysia
Elysia: Maximum Performance on Bun
Elysia is built specifically for Bun and squeezes every bit of performance out of the runtime. It has the best TypeScript inference of any framework and competitive performance with Go and Rust frameworks.
Basic Elysia App
import { Elysia, t } from "elysia";
const app = new Elysia()
.get("/", () => "Hello World")
.get("/users/:id", ({ params: { id } }) => getUser(id), {
params: t.Object({
id: t.String({ format: "uuid" }),
}),
})
.post(
"/users",
({ body }) => createUser(body),
{
body: t.Object({
name: t.String({ minLength: 1 }),
email: t.String({ format: "email" }),
}),
response: {
201: t.Object({
id: t.String(),
name: t.String(),
email: t.String(),
}),
},
}
)
.listen(3000);
console.log(`Running at ${app.server?.hostname}:${app.server?.port}`);
Elysia's Type System
Elysia's type inference is remarkable. The framework infers types from your schema definitions and propagates them through the entire request lifecycle:
const app = new Elysia()
.state("version", "1.0.0")
.decorate("db", createDbConnection())
.model({
user: t.Object({
id: t.String(),
name: t.String(),
email: t.String(),
}),
createUser: t.Object({
name: t.String({ minLength: 1 }),
email: t.String({ format: "email" }),
}),
})
.get("/version", ({ store }) => store.version) // store.version is typed as string
.post(
"/users",
async ({ body, db }) => {
// body is typed as { name: string; email: string }
// db is typed from the decorate call above
const user = await db.users.create(body);
return user;
},
{
body: "createUser", // References the model defined above
response: { 201: "user" },
}
);
Elysia Eden (End-to-End Type Safety)
Like Hono's RPC, but with even tighter type inference:
// server.ts
export const app = new Elysia()
.get("/users", () => db.users.findMany())
.post("/users", ({ body }) => db.users.create(body), {
body: t.Object({
name: t.String(),
email: t.String({ format: "email" }),
}),
});
export type App = typeof app;
// client.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "../server";
const client = treaty<App>("localhost:3000");
// Fully typed request and response
const { data, error } = await client.users.get();
// data is User[] | null, error is typed error
const { data: newUser } = await client.users.post({
name: "Alice",
email: "[email protected]",
});
Elysia Plugin System
import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
const app = new Elysia()
.use(cors())
.use(swagger()) // Auto-generates OpenAPI docs at /swagger
.use(
jwt({
name: "jwt",
secret: process.env.JWT_SECRET!,
})
)
.derive(async ({ jwt, headers }) => {
const token = headers.authorization?.replace("Bearer ", "");
if (!token) return { user: null };
const user = await jwt.verify(token);
return { user };
})
.guard({ beforeHandle: ({ user }) => {
if (!user) return new Response("Unauthorized", { status: 401 });
}})
.get("/profile", ({ user }) => user) // user is typed, guaranteed non-null
.listen(3000);
When to Choose Elysia
- You're all-in on Bun as your runtime
- You want the best TypeScript inference of any framework
- Raw performance is a priority (benchmarks rival Go/Rust frameworks)
- You want automatic OpenAPI generation from your type definitions
- End-to-end type safety with Eden fits your architecture
Performance Benchmarks in Context
Raw request-per-second benchmarks are useful directionally but misleading in isolation. Here's why:
Framework | Hello World (req/s) | JSON (req/s) | DB Query (req/s)
---------------|--------------------:|-------------:|----------------:
Elysia (Bun) | ~100,000 | ~90,000 | ~25,000
Hono (Bun) | ~85,000 | ~75,000 | ~23,000
Fastify (Node) | ~55,000 | ~48,000 | ~20,000
Express (Node) | ~15,000 | ~13,000 | ~10,000
Notice the DB query column -- the gap narrows dramatically when you add real work. In production, your bottleneck is almost always the database, external API calls, or business logic. Framework overhead matters most for I/O-light endpoints like health checks and static responses.
Choose based on developer experience and ecosystem fit, not benchmarks. The performance difference between Fastify and Elysia rarely matters in practice. The difference between Express and everything else sometimes does.
Migration Path from Express
If you're moving away from Express, here's a practical migration strategy:
Step 1: Run Both Frameworks Side by Side
// Gradually migrate routes from Express to Fastify/Hono
import express from "express";
import { Hono } from "hono";
const legacyApp = express();
const newApp = new Hono();
// New routes go to Hono
newApp.get("/api/v2/users", async (c) => {
return c.json(await getUsers());
});
// Express handles everything else
// Use a reverse proxy or router to split traffic
Step 2: Migrate Middleware
Most Express middleware has equivalents in other frameworks:
| Express Middleware | Fastify Equivalent | Hono Equivalent |
|---|---|---|
| cors | @fastify/cors | hono/cors |
| helmet | @fastify/helmet | hono/secure-headers |
| express-rate-limit | @fastify/rate-limit | hono/rate-limiter |
| passport | @fastify/passport | Custom (or lucia) |
| multer | @fastify/multipart | hono/multipart |
| compression | @fastify/compress | hono/compress |
Step 3: Update Tests
// Express tests (supertest)
import request from "supertest";
const res = await request(app).get("/users").expect(200);
// Hono tests (built-in)
const res = await app.request("/users");
expect(res.status).toBe(200);
// Elysia tests (built-in)
const res = await app.handle(new Request("http://localhost/users"));
expect(res.status).toBe(200);
Decision Matrix
| If you need... | Choose |
|---|---|
| Maximum ecosystem and learning resources | Express |
| Best performance on Node.js with mature ecosystem | Fastify |
| Multi-runtime deployment (edge, serverless, Node) | Hono |
| Maximum performance on Bun with best TypeScript DX | Elysia |
| Gradual migration from Express on Node.js | Fastify |
| Cloudflare Workers or Deno Deploy | Hono |
| End-to-end type safety without codegen | Hono (RPC) or Elysia (Eden) |
Summary
Express is showing its age but remains viable for existing projects. For new projects on Node.js, Fastify is the safe, performant choice with a mature ecosystem. Hono is the right pick if you need runtime portability or are deploying to edge/serverless environments. Elysia is the performance leader with the best TypeScript experience, but it locks you into Bun. Pick the framework that matches your runtime and team, not the one with the best benchmarks.