h3 vs polka vs koa: Lightweight HTTP Frameworks for Node.js (2026)
TL;DR
h3 is the UnJS HTTP framework — minimal, event-based, works on Node.js, Deno, Bun, Cloudflare Workers, and edge runtimes, powers Nitro and Nuxt server routes. polka is the Express alternative — same API as Express but 33% faster, tiny footprint, drop-in middleware compatibility. koa is the next-generation Express — created by the Express team, async/await-first with ctx context pattern, cascading middleware. In 2026: h3 for edge-first and UnJS ecosystem, polka for Express-compatible lightweight servers, koa for elegant async middleware patterns.
Key Takeaways
- h3: ~5M weekly downloads — UnJS, edge-ready, event-based, powers Nitro/Nuxt
- polka: ~3M weekly downloads — Express-compatible, 33% faster, same middleware
- koa: ~2M weekly downloads — async/await, ctx pattern, created by Express team
- All three are minimal alternatives to Express with lower overhead
- h3 is the only one designed for edge runtimes (Workers, Deno Deploy)
- polka is the easiest migration from Express (same API)
h3
h3 — minimal HTTP framework for any runtime:
Basic server
import { createApp, createRouter, eventHandler, toNodeListener } from "h3"
import { createServer } from "node:http"
const app = createApp()
const router = createRouter()
router.get("/", eventHandler(() => {
return { message: "Hello from h3!" }
}))
router.get("/api/packages/:name", eventHandler((event) => {
const name = getRouterParam(event, "name")
return { package: name }
}))
app.use(router)
createServer(toNodeListener(app)).listen(3000)
Event handlers
import {
createApp, createRouter, eventHandler,
readBody, getQuery, getRouterParam,
setResponseStatus, setResponseHeader,
createError,
} from "h3"
const router = createRouter()
// GET with query parameters:
router.get("/api/packages", eventHandler(async (event) => {
const { page, limit } = getQuery(event)
const packages = await db.query("SELECT * FROM packages LIMIT $1 OFFSET $2", [
limit ?? 20,
((page ?? 1) - 1) * (limit ?? 20),
])
return { data: packages, page, limit }
}))
// POST with body:
router.post("/api/packages", eventHandler(async (event) => {
const body = await readBody(event)
if (!body.name) {
throw createError({ statusCode: 400, message: "Name is required" })
}
const pkg = await db.insert("packages", body)
setResponseStatus(event, 201)
return pkg
}))
// Response headers:
router.get("/api/health", eventHandler((event) => {
setResponseHeader(event, "Cache-Control", "no-cache")
return { status: "ok", timestamp: Date.now() }
}))
Middleware
import { createApp, eventHandler, getHeader, createError } from "h3"
const app = createApp()
// Global middleware (runs for all routes):
app.use(eventHandler((event) => {
// Logging:
console.log(`${event.method} ${event.path}`)
// Return nothing to continue to next handler
}))
// Auth middleware:
app.use("/api", eventHandler((event) => {
const token = getHeader(event, "authorization")
if (!token) {
throw createError({ statusCode: 401, message: "Unauthorized" })
}
event.context.user = verifyToken(token)
}))
Edge runtime support
// h3 works on Cloudflare Workers, Deno, Bun — same code:
// Cloudflare Workers:
import { createApp, createRouter, eventHandler, toWebHandler } from "h3"
const app = createApp()
const router = createRouter()
router.get("/", eventHandler(() => ({ hello: "from the edge" })))
app.use(router)
export default { fetch: toWebHandler(app) }
// Deno:
import { serve } from "https://deno.land/std/http/server.ts"
serve(toWebHandler(app), { port: 3000 })
How Nuxt uses h3
// Nuxt server routes are h3 event handlers:
// server/api/packages.get.ts
export default eventHandler(async (event) => {
const query = getQuery(event)
return await fetchPackages(query)
})
// server/api/packages/[name].get.ts
export default eventHandler(async (event) => {
const name = getRouterParam(event, "name")
return await getPackage(name)
})
// server/middleware/auth.ts
export default eventHandler((event) => {
event.context.auth = validateSession(event)
})
polka
polka — Express alternative:
Basic server
import polka from "polka"
polka()
.get("/", (req, res) => {
res.end("Hello from polka!")
})
.get("/api/packages/:name", (req, res) => {
res.end(JSON.stringify({ package: req.params.name }))
})
.listen(3000, () => {
console.log("Running on port 3000")
})
Express middleware compatibility
import polka from "polka"
import cors from "cors"
import helmet from "helmet"
import { json } from "@polka/parse"
// Express middleware works directly:
polka()
.use(cors())
.use(helmet())
.use(json())
.get("/api/packages", (req, res) => {
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ packages: [] }))
})
.post("/api/packages", (req, res) => {
// req.body available via @polka/parse:
const { name } = req.body
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ created: name }))
})
.listen(3000)
Sub-applications
import polka from "polka"
// API routes:
const api = polka()
.get("/packages", (req, res) => {
res.end(JSON.stringify({ packages: [] }))
})
.get("/packages/:name", (req, res) => {
res.end(JSON.stringify({ name: req.params.name }))
})
// Mount sub-app:
polka()
.use("/api", api)
.get("/", (req, res) => {
res.end("Homepage")
})
.listen(3000)
polka vs Express performance
Benchmark (requests/sec, simple JSON response):
polka: ~48,000 req/sec
Express: ~36,000 req/sec (33% slower)
Node.js: ~52,000 req/sec (raw http)
polka gets ~92% of raw Node.js performance
Express gets ~69% of raw Node.js performance
polka achieves this by:
- No req/res decoration (Express adds ~50 properties)
- Simpler route matching
- Fewer internal middleware layers
koa
koa — async/await middleware framework:
Basic server
import Koa from "koa"
const app = new Koa()
app.use(async (ctx) => {
ctx.body = { message: "Hello from Koa!" }
})
app.listen(3000)
Context pattern
import Koa from "koa"
import Router from "@koa/router"
const app = new Koa()
const router = new Router()
router.get("/api/packages", async (ctx) => {
const { page, limit } = ctx.query
const packages = await db.query("SELECT * FROM packages")
ctx.body = { data: packages, page, limit }
})
router.get("/api/packages/:name", async (ctx) => {
const { name } = ctx.params
const pkg = await db.findOne("packages", { name })
if (!pkg) {
ctx.status = 404
ctx.body = { error: "Package not found" }
return
}
ctx.body = pkg
})
router.post("/api/packages", async (ctx) => {
const body = ctx.request.body
const pkg = await db.insert("packages", body)
ctx.status = 201
ctx.body = pkg
})
app.use(router.routes())
app.use(router.allowedMethods())
app.listen(3000)
Cascading middleware
import Koa from "koa"
const app = new Koa()
// Logger middleware — cascading (runs before AND after):
app.use(async (ctx, next) => {
const start = Date.now()
await next() // Wait for downstream middleware
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}ms - ${ctx.status}`)
})
// Error handler:
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.status || 500
ctx.body = { error: err.message }
ctx.app.emit("error", err, ctx)
}
})
// Auth middleware:
app.use(async (ctx, next) => {
const token = ctx.get("Authorization")
if (ctx.path.startsWith("/api") && !token) {
ctx.throw(401, "Authentication required")
}
ctx.state.user = token ? verifyToken(token) : null
await next()
})
// Response (innermost middleware):
app.use(async (ctx) => {
ctx.body = { user: ctx.state.user, data: "protected" }
})
Koa vs Express patterns
// Express pattern — callback-based:
app.use((req, res, next) => {
const start = Date.now()
res.on("finish", () => {
console.log(`${req.method} ${req.url} - ${Date.now() - start}ms`)
})
next()
})
// Koa pattern — async/await cascade:
app.use(async (ctx, next) => {
const start = Date.now()
await next()
console.log(`${ctx.method} ${ctx.url} - ${Date.now() - start}ms`)
})
// Koa's cascade is more intuitive —
// "before" logic is above next(), "after" logic is below
Feature Comparison
| Feature | h3 | polka | koa |
|---|---|---|---|
| API style | Event handlers | req/res (Express) | ctx (context) |
| Middleware | Sequential | Sequential (Express) | Cascading (onion) |
| Edge runtimes | ✅ (Workers, Deno) | ❌ (Node.js only) | ❌ (Node.js only) |
| Express middleware compat | ❌ | ✅ | ⚠️ (with adapters) |
| Built-in router | ✅ (radix3) | ✅ (trouter) | ❌ (@koa/router) |
| Body parsing | ✅ (readBody) | ❌ (@polka/parse) | ❌ (koa-body) |
| TypeScript | ✅ | ⚠️ | ✅ |
| Used by | Nuxt, Nitro | SvelteKit (adapter) | Many apps |
| Weekly downloads | ~5M | ~3M | ~2M |
When to Use Each
Use h3 if:
- Need edge runtime support (Cloudflare Workers, Deno Deploy)
- Building with Nuxt or Nitro (UnJS ecosystem)
- Want a runtime-agnostic HTTP framework
- Need built-in body parsing, routing, and error handling
Use polka if:
- Migrating from Express and want a faster alternative
- Need Express middleware compatibility (cors, helmet, etc.)
- Want the simplest possible upgrade path
- Building a Node.js-only server
Use koa if:
- Want elegant async/await middleware patterns
- Need cascading middleware (before + after hooks)
- Building apps with complex middleware chains
- Prefer the ctx context pattern over req/res
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on h3 v1.x, polka v1.x, and koa v2.x.