Skip to main content

h3 vs polka vs koa: Lightweight HTTP Frameworks for Node.js (2026)

·PkgPulse Team

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

Featureh3polkakoa
API styleEvent handlersreq/res (Express)ctx (context)
MiddlewareSequentialSequential (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 byNuxt, NitroSvelteKit (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.

Compare HTTP frameworks and server tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.