Skip to main content

Hono vs Express vs Fastify vs Elysia 2026

·PkgPulse Team
0

Four HTTP frameworks, four different answers to what "fast" and "developer-friendly" mean in 2026. Express is the incumbent with 35 million weekly downloads and a maintenance-mode codebase. Fastify proved you can be fast without abandoning Node.js conventions. Hono showed that a framework targeting web standards runs everywhere, not just Node.js. Elysia bet on Bun and TypeScript-first design to deliver the highest raw throughput with the best type inference.

If you're starting a new API in 2026, the choice matters more than it did in 2020 when Express was the obvious default.

TL;DR

New Node.js API: Fastify — 3x faster than Express, JSON schema validation, plugin lifecycle, Node.js native. Multi-runtime / Edge: Hono — runs on Cloudflare Workers, Bun, Deno, and Node.js from one codebase. Bun-first services: Elysia — highest throughput on Bun, end-to-end TypeScript type safety, ElysiaJS ecosystem. Existing codebases: Express — don't rewrite working code; migrate to Fastify when performance becomes a bottleneck.

Key Takeaways

  • Elysia on Bun: ~71K req/s without validation, ~33K req/s with TypeBox validation
  • Hono: ~62K req/s, runs on 10+ platforms, ~15KB bundle, WinterCG-compliant
  • Fastify: ~15K req/s on Node.js, 3x faster than Express, schema-driven JSON serialization
  • Express: ~8-10K req/s, 35M weekly downloads, effectively in maintenance mode
  • TypeScript DX: Elysia (best — full inference) > Hono (excellent) > Fastify (good) > Express (afterthought)
  • Ecosystem: Express (largest) > Fastify > Hono > Elysia

At a Glance

Express 5Fastify 5Hono 4Elysia 1.x
req/s (Node.js)~8-10K~15K~20-25K~15-18K
req/s (Bun)~15K~18K~55-62K~65-71K
Bundle size~200KB~280KB~15KB~30KB
TypeScriptTypes via @types/expressGood, built-inExcellentBest-in-class
PlatformsNode.jsNode.jsNode/Bun/Deno/CF/EdgeBun (primary), Node.js
Schema validationManual (Zod/Joi)Built-in (Ajv)Built-in (Zod/TypeBox)Built-in (TypeBox)
Weekly downloads~35M~4M~3M~700K

Express 5: The Incumbent

Express.js turned 14 in 2023 and released version 5.0 in 2024 after 10 years of beta. Express 5 is not a performance release — it's a cleanup release that drops Node.js 0.x support, improves async error handling, and removes deprecated APIs.

What Express 5 Changed

The most important Express 5 change for most codebases: async errors are now handled automatically. In Express 4, throwing in an async route handler silently crashed or hung:

// Express 4 — error not caught, request hangs
app.get('/user/:id', async (req, res) => {
  const user = await db.users.findOne(req.params.id) // If this throws, nothing happens
  res.json(user)
})

// Express 4 workaround — manual try/catch everywhere
app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await db.users.findOne(req.params.id)
    res.json(user)
  } catch (err) {
    next(err)
  }
})

Express 5 routes handle async errors natively — if the Promise rejects, it's passed to your error handler middleware automatically. This eliminates a major footgun but doesn't change Express's performance characteristics.

Why Express Is Still Deployed Everywhere

Express runs on approximately 35% of all Node.js production servers because it was the default choice for a decade. The middleware ecosystem is enormous: passport, helmet, morgan, cors, compression, multer, express-validator — all battle-tested, all well-documented. For teams inheriting existing codebases, rewriting to Fastify or Hono means migrating all of these middleware integrations.

The performance profile of Express is genuinely its weakness:

  • No built-in schema validation or serialization
  • JSON serialization uses JSON.stringify without optimization
  • Middleware stack is synchronous and blocking
  • No connection multiplexing or HTTP/2 optimization

For most CRUD APIs with database bottlenecks, Express's raw throughput deficit doesn't matter. The bottleneck is the DB query, not the framework overhead. When you're making 10 outbound DB calls per request, the difference between 8K and 15K req/s is academic.

Hello World + Middleware

import express from 'express'
import helmet from 'helmet'
import cors from 'cors'

const app = express()

app.use(express.json())
app.use(helmet())
app.use(cors({ origin: 'https://example.com' }))

app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`)
  next()
})

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: Date.now() })
})

app.get('/users/:id', async (req, res) => {
  const user = await db.users.findOne(req.params.id)
  res.json(user)
})

app.listen(3000, () => console.log('Express running on :3000'))

Fastify 5: Node.js Performance Done Right

Fastify is what you get when you keep Node.js conventions but optimize aggressively for throughput. The 3x performance advantage over Express comes from three architectural decisions: JSON schema validation, fast JSON serialization via fast-json-stringify, and a plugin lifecycle that avoids middleware overhead.

Schema Validation

Fastify validates request bodies and serializes response bodies using JSON Schema. The validation uses Ajv, which compiles schemas to optimized validation functions. The serialization uses fast-json-stringify, which generates serialization code from your response schema at startup, avoiding runtime JSON.stringify reflection:

import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

fastify.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['name', 'email'],
      properties: {
        name: { type: 'string', minLength: 1 },
        email: { type: 'string', format: 'email' },
      },
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'string' },
          name: { type: 'string' },
          email: { type: 'string' },
        },
      },
    },
  },
  async handler(request, reply) {
    const user = await db.users.create(request.body)
    reply.status(201).send(user)
  },
})

await fastify.listen({ port: 3000 })

The response schema does double duty: it validates the response shape during development and generates optimized serialization code for production. A route with a defined response schema serializes 2-3x faster than JSON.stringify.

Plugin System

Fastify's plugin system uses fastify-plugin to scope middleware to specific routes or route groups. Unlike Express's global middleware stack, Fastify plugins can encapsulate routes, decorators, and hooks:

import fp from 'fastify-plugin'

// Authentication plugin scoped to /api routes
async function authPlugin(fastify, options) {
  fastify.addHook('preHandler', async (request, reply) => {
    const token = request.headers.authorization
    if (!token) return reply.status(401).send({ error: 'Unauthorized' })
    request.user = await verifyToken(token)
  })
}

// Register only for /api prefix
fastify.register(async (instance) => {
  instance.register(fp(authPlugin))
  instance.get('/profile', async (req) => ({ user: req.user }))
}, { prefix: '/api' })

// Public route, no auth
fastify.get('/health', async () => ({ status: 'ok' }))

TypeScript Support

Fastify 5 has good TypeScript support via generics:

import Fastify from 'fastify'

const fastify = Fastify()

fastify.get<{
  Params: { id: string }
  Reply: { id: string; name: string }
}>('/users/:id', async (request, reply) => {
  const { id } = request.params // typed as string
  const user = await db.users.findOne(id)
  reply.send(user) // must match Reply type
})

The generics approach works but requires explicit type annotations on each route. Fastify's type inference doesn't flow automatically from schema definitions to handler types.

Hello World + Middleware

import Fastify from 'fastify'
import cors from '@fastify/cors'

const fastify = Fastify()
await fastify.register(cors, { origin: 'https://example.com' })

fastify.addHook('onRequest', async (request) => {
  console.log(`${request.method} ${request.url}`)
})

fastify.get('/health', async () => ({ status: 'ok', timestamp: Date.now() }))

await fastify.listen({ port: 3000 })

Hono 4: Web Standards Everywhere

Hono is built on the Fetch API — Request, Response, Headers, URL. This means a Hono application runs without modification on Cloudflare Workers, Bun, Deno, AWS Lambda, Vercel Edge, and Node.js. The runtime adapter is a one-line change.

Multi-Runtime Architecture

// src/app.ts — same code runs everywhere
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { bearerAuth } from 'hono/bearer-auth'

const app = new Hono()

app.use('*', logger())
app.use('/api/*', bearerAuth({ token: process.env.API_TOKEN! }))
app.use('/api/*', cors())

app.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }))

app.get('/api/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await db.users.findOne(id)
  return c.json(user)
})

export default app
// index.ts for Node.js
import { serve } from '@hono/node-server'
import app from './app'
serve({ fetch: app.fetch, port: 3000 })

// index.ts for Bun
import app from './app'
export default { port: 3000, fetch: app.fetch }

// index.ts for Cloudflare Workers
import app from './app'
export default app // Workers use the default export

Same app.ts file, three different entry points. For teams deploying across multiple platforms (Node.js in development, Cloudflare Workers in production), this portability is Hono's killer feature.

TypeScript and RPC

Hono has first-class TypeScript support with full route type inference:

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})

const app = new Hono()
  .post('/users', zValidator('json', userSchema), async (c) => {
    const body = c.req.valid('json') // Typed as { name: string; email: string }
    const user = await db.users.create(body)
    return c.json(user, 201)
  })

export type AppType = typeof app

With hono/client, the client can infer route types without code generation:

import { hc } from 'hono/client'
import type { AppType } from '../server/app'

const client = hc<AppType>('https://api.example.com')
const res = await client.users.$post({ json: { name: 'Alice', email: 'alice@example.com' } })
// TypeScript knows the response type from the server route definition

Hello World + Middleware

import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { serve } from '@hono/node-server'

const app = new Hono()

app.use('*', logger())
app.use('*', cors())

app.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }))

serve({ fetch: app.fetch, port: 3000 })

Bundle Size Advantage

Hono's ~15KB bundle is a significant advantage for edge deployments. Cloudflare Workers have a 1MB script size limit (10MB on paid plans). Fastify at ~280KB and its plugins consume much more of that budget. Many production Hono deployments on Cloudflare Workers fit in under 200KB total.

Elysia 1.x: Bun-Native TypeScript

Elysia is designed specifically for Bun, leveraging Bun's Zig-written HTTP server internals and JavaScriptCore engine to achieve throughput that Node.js-targeting frameworks can't match.

End-to-End Type Safety

Elysia's type system is the framework's defining feature. Types flow from route definition through validation to the response without any code generation step:

import { Elysia, t } from 'elysia'

const app = new Elysia()
  .post('/users', ({ body }) => {
    // body is typed as { name: string; email: string }
    // TypeScript error if you access body.phone (not in schema)
    return { id: crypto.randomUUID(), ...body }
  }, {
    body: t.Object({
      name: t.String({ minLength: 1 }),
      email: t.String({ format: 'email' }),
    }),
    response: t.Object({
      id: t.String(),
      name: t.String(),
      email: t.String(),
    }),
  })

The t object is TypeBox (JSON Schema TypeScript types). Validation runs at the framework level before the handler executes — no manual Zod parsing, no separate validation step.

Eden Client

Elysia's Eden client provides Hono RPC-equivalent type safety with automatic type inference:

// Client code
import { treaty } from '@elysiajs/eden'
import type { App } from '../server'

const client = treaty<App>('localhost:3000')

const { data, error } = await client.users.post({
  name: 'Alice',
  email: 'alice@example.com',
})
// data is typed as { id: string; name: string; email: string }

No OpenAPI generation, no code gen step — types are inferred directly from the server application type.

Plugin Architecture

Elysia's plugin system uses method chaining to compose application layers:

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-generated OpenAPI docs
  .use(jwt({ secret: process.env.JWT_SECRET! }))
  .derive(({ jwt, headers }) => ({
    // Derive auth context available in all subsequent routes
    auth: async () => jwt.verify(headers.authorization?.slice(7) ?? ''),
  }))
  .get('/health', () => ({ status: 'ok', timestamp: Date.now() }))
  .guard({ beforeHandle: [({ auth }) => auth()] }, (app) =>
    app.get('/profile', async ({ auth }) => {
      const user = await auth()
      return { user }
    })
  )
  .listen(3000)

Performance on Bun

Elysia's numbers on Bun are the highest of the four frameworks:

  • Hello world (no validation): ~71K req/s
  • With TypeBox validation: ~33K req/s

For comparison, the same Hono app on Bun runs ~55-62K req/s. The Elysia advantage comes from tighter integration with Bun's HTTP internals and the TypeBox validation being compiled at startup.

On Node.js, Elysia runs but the performance advantage shrinks — Bun's HTTP stack is what drives the numbers. For Node.js-only deployments, Hono or Fastify are better choices.

Hello World + Middleware

import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'

const app = new Elysia()
  .use(cors())
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`)
  })
  .get('/health', () => ({ status: 'ok', timestamp: Date.now() }))
  .listen(3000)

console.log('Elysia running on :3000')

Benchmark Table

Running on a standard benchmark setup (hello world endpoint, keep-alive, wrk):

FrameworkRuntimereq/sLatency p99Memory
ElysiaBun~71,000~3ms~35MB
HonoBun~62,000~4ms~30MB
HonoCloudflare Workers~85,000+<1msN/A
FastifyNode.js 24~15,700~12ms~55MB
HonoNode.js 24~20,000~10ms~45MB
ExpressBun~15,000~15ms~50MB
ExpressNode.js 24~8,000-10,000~25ms~60MB

Hello-world benchmarks are misleading for real apps. DB-bound APIs converge at ~3-8K req/s regardless of framework. Use these numbers to understand theoretical maximums, not to predict production throughput.

Migration from Express to Hono

For teams on Express who want multi-runtime support without a full rewrite, Hono is the closest migration path. The route handler API is similar.

Route Handler Mapping

// Express
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findOne(req.params.id)
  res.json(user)
})

// Hono equivalent
app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await db.users.findOne(id)
  return c.json(user)
})

Middleware Mapping

// Express middleware
app.use((req, res, next) => {
  req.requestId = crypto.randomUUID()
  next()
})

// Hono middleware
app.use((c, next) => {
  c.set('requestId', crypto.randomUUID())
  return next()
})

Error Handling

// Express error middleware
app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).json({ error: 'Internal Server Error' })
})

// Hono error handler
app.onError((err, c) => {
  console.error(err)
  return c.json({ error: 'Internal Server Error' }, 500)
})

The main migration effort is replacing Express-specific middleware (passport, multer, express-session) with Hono-compatible alternatives. Most have direct equivalents in Hono's ecosystem or can be wrapped using Hono's fromHono adapter for Web Standards-compatible middleware.

When to Use Each

Express: You're inheriting or extending an existing Express application. The ecosystem investment outweighs the performance deficit. Don't rewrite working code.

Fastify: New Node.js APIs where you need performance, JSON schema validation is a good fit, and you want mature Node.js ecosystem compatibility. The best drop-in upgrade path from Express on Node.js.

Hono: Multi-runtime deployment (any combination of Node.js, Bun, Cloudflare Workers, Deno, Vercel Edge). Edge-first applications where bundle size matters. Strong TypeScript with the Hono RPC pattern.

Elysia: Bun-native services where you want maximum throughput. TypeScript-first teams who want end-to-end type inference without code generation. Projects that can commit to the Bun ecosystem.

Middleware Ecosystem

Middleware is where Express's age becomes both its biggest asset and its biggest liability. The Express ecosystem has 15 years of middleware packages — virtually every common concern (auth, rate limiting, compression, logging, file uploads) has a battle-tested solution. Newer frameworks have smaller ecosystems but cleaner APIs for the most common middleware patterns.

CORS

All four frameworks handle CORS, but the implementation details differ:

// Express — npm install cors
import cors from 'cors'
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  credentials: true,
}))

// Fastify — npm install @fastify/cors
await fastify.register(import('@fastify/cors'), {
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
})

// Hono — built-in, no install needed
import { cors } from 'hono/cors'
app.use('*', cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
}))

// Elysia — npm install @elysiajs/cors
import { cors } from '@elysiajs/cors'
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
}))

Hono's advantage is having CORS (and several other common middleware) built into the framework package — no extra install and no version mismatch between the framework and its middleware packages.

Rate Limiting

Rate limiting exposes deeper ecosystem differences. Express has express-rate-limit (widely used, Redis-backed). Fastify has @fastify/rate-limit. Hono and Elysia require third-party packages or custom middleware:

// Express — npm install express-rate-limit
import rateLimit from 'express-rate-limit'
app.use('/api/', rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
}))

// Fastify — npm install @fastify/rate-limit
await fastify.register(import('@fastify/rate-limit'), {
  max: 100,
  timeWindow: '15 minutes',
})

// Hono — custom middleware using Cloudflare KV or in-memory
app.use('/api/*', async (c, next) => {
  const ip = c.req.header('CF-Connecting-IP') ?? 'unknown'
  const key = `rate:${ip}`
  const count = (await kv.get(key) ?? 0) as number
  if (count >= 100) return c.json({ error: 'Rate limit exceeded' }, 429)
  await kv.put(key, count + 1, { expirationTtl: 900 })
  return next()
})

For Cloudflare Workers deployments, Hono's lack of a rate-limit package is less of a gap — Cloudflare has built-in rate limiting at the edge. For Node.js deployments, the gap is real and the custom middleware approach requires more code.

Logger Middleware

// Express — npm install morgan
import morgan from 'morgan'
app.use(morgan('combined'))

// Fastify — built-in pino logger
const fastify = Fastify({ logger: { level: 'info' } })
// All requests logged automatically

// Hono — built-in logger middleware
import { logger } from 'hono/logger'
app.use('*', logger())

// Elysia — lifecycle hooks
app.onRequest(({ request }) => {
  console.log(`→ ${request.method} ${new URL(request.url).pathname}`)
})
.onAfterHandle(({ request, set }) => {
  console.log(`← ${set.status} ${new URL(request.url).pathname}`)
})

Fastify's built-in Pino logger is the standout here — Pino is a high-performance structured logger that serializes to JSON with minimal overhead. For production observability, Fastify's logging story is more complete out of the box than any of the alternatives.

Express Middleware Compatibility in Hono

For teams migrating from Express, Hono provides an adapter for Express-style middleware that follows the (req, res, next) signature:

import { Hono } from 'hono'
import { createMiddleware } from 'hono/factory'

// Adapt an Express-style middleware to Hono
function adaptExpressMiddleware(expressMiddleware: Function) {
  return createMiddleware(async (c, next) => {
    await new Promise<void>((resolve, reject) => {
      expressMiddleware(c.req.raw, c.res, (err?: unknown) => {
        if (err) reject(err)
        else resolve()
      })
    })
    return next()
  })
}

This works for stateless Express middleware (rate limiting, logging) but not for middleware that writes to res directly. Middleware that calls res.json() or res.send() cannot be adapted — those patterns must be rewritten using Hono's context API.

Ecosystem Maturity Summary

ConcernExpressFastifyHonoElysia
CORScors (npm)@fastify/corsBuilt-in@elysiajs/cors
Rate limitingexpress-rate-limit@fastify/rate-limitCustom / CF built-inCustom
Loggingmorgan / pino-httpBuilt-in PinoBuilt-in loggerLifecycle hooks
Auth / JWTpassport, jsonwebtoken@fastify/jwthono/jwt@elysiajs/jwt
File uploadmulter@fastify/multipartBuilt-in parseBodyBuilt-in form parser
Compressioncompression@fastify/compresshono/compressCustom
OpenAPI docsswagger-jsdoc@fastify/swagger@hono/swagger-ui@elysiajs/swagger

Express wins on ecosystem breadth and battle-tested packages. Fastify wins on built-in functionality and official plugin quality. Hono wins on having common middleware built into the core package without extra installs. Elysia's ecosystem is the smallest but growing rapidly.

Comments

Get the 2026 npm Stack Cheatsheet

Our top package picks for every category — ORMs, auth, testing, bundlers, and more. Plus weekly npm trend reports.