← All articles
BACKEND Backend Framework Comparison: Express vs Fastify vs ... 2026-02-09 · 10 min read · backend · express · fastify

Backend Framework Comparison: Express vs Fastify vs Hono vs Elysia

Backend 2026-02-09 · 10 min read backend express fastify hono elysia typescript api

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

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

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

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

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.