What is Koa?
Koa is a minimal web framework for Node.js written by the same team behind Express. Where Express grew a large middleware ecosystem and the familiar req/res API, Koa started over with two goals: a tiny core and first-class async/await. It is less a competitor to Express than a deliberate rethink of it.
The original Express author, TJ Holowaychuk, built Koa to fix what felt awkward about Express at the time — nested callbacks, ad-hoc error handling and a core that had accumulated more than its authors wanted. Koa 1 used generator functions. Koa 2 arrived after Node 7.6 brought native async/await, and that is the version you will use today.
If Express teaches you the middleware chain, Koa teaches you what happens when that chain is allowed to wrap around a request instead of only passing through it.
The context object
Koa collapses the request and the response into a single object called the context, conventionally named ctx. Instead of reading req and writing to res, you read and write properties on one object.
app.use(async (ctx) => {
ctx.status = 200; // response status
ctx.type = "application/json"; // response content type
ctx.body = { ok: true }; // response body
});
The request and response are still available in full when you need them:
ctx.request— the wrapped incoming message (ctx.request.body,ctx.request.header).ctx.response— the wrapped outgoing message (ctx.response.status).ctx.params,ctx.queryandctx.request.body— routing and parsed input.ctx.state— a plain object for passing values between middleware, such as the authenticated user.ctx.throw(status, message)— raise an HTTP error that the error middleware can catch.
ctx.state is the idiomatic place to attach shared data. Authentication middleware sets ctx.state.user, and later middleware or the route reads it, without polluting the request object itself.
The onion: middleware that wraps
Koa middleware is a single async function with the signature (ctx, next). Calling await next() passes control inward; whatever you write after that line runs once the inner layers have completed.
request ──▶ mw1 before ──▶ mw2 before ──▶ route
│
response ◀── mw1 after ◀── mw2 after ◀────────┘
This is the onion model, and it is the single idea that makes Koa distinctive. A flat chain can only run code before the response; the onion can run code on both sides.
app.use(async (ctx, next) => {
const start = Date.now();
await next(); // everything downstream runs here
ctx.set("X-Response-Time", `${Date.now() - start}ms`);
});
That one middleware times the entire request, including every route and every other middleware registered below it. The same pattern handles logging, database transactions, and cleanup.
A middleware can also short-circuit the chain: if it sets ctx.body and never calls next(), the request ends there. That is how authentication rejects a request before it reaches a route.
Routing with @koa/router
Koa has no router in its core, so routing is provided by @koa/router, the community-maintained package.
import Koa from "koa";
import Router from "@koa/router";
const app = new Koa();
const router = new Router();
router.get("/posts", async (ctx) => {
ctx.body = await Post.find().limit(20);
});
router.get("/posts/:id", async (ctx) => {
const post = await Post.findById(ctx.params.id);
if (!post) ctx.throw(404, "post not found");
ctx.body = post;
});
app.use(router.routes());
app.use(router.allowedMethods());
router.routes() mounts the matching handlers, and router.allowedMethods() answers with a correct 405 when the path exists but the method does not. Routers can be nested and prefixed, which keeps a large API modular in the same way express.Router() does.
Error handling and app.on(“error”)
Because Koa middleware are ordinary async functions, errors are ordinary exceptions. Catch them in one place by wrapping the downstream call.
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status ?? 500;
ctx.body = { error: err.expose ? err.message : "internal_error" };
ctx.app.emit("error", err, ctx);
}
});
Inside a handler, ctx.throw(404, "not found") creates an error with a status, a message and an expose flag that marks it as safe to show to clients. Errors without a status become 500, and their message is hidden from the response.
Not every error can be turned into a response. If a failure happens after the headers are sent, Koa emits an error event on the app instead:
app.on("error", (err, ctx) => {
console.error(`${ctx.method} ${ctx.url}`, err);
});
Subscribe to that event no matter what, so unexpected failures are always logged.
Why Koa has no built-in router or body parser
Koa’s core is intentionally almost empty. It gives you the context, the middleware pipeline and the HTTP plumbing; routing, body parsing, cookies, sessions, static files and security headers all live in separate packages.
This is a deliberate trade. A tiny core is easy to audit, has few dependencies and evolves slowly, so it is hard for Koa itself to break your app. The cost is that you own the composition: you decide which body parser to add, how to parse it, and where to register it.
import bodyParser from "koa-bodyparser";
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
Order matters here just as it does in Express. A parser registered after the router will not be available to the routes.
Koa or Express?
Both frameworks share the same request/response ideas, so the choice is mostly about philosophy.
Choose Express when familiarity matters most: it is the most widely used Node framework, ships with routing, and has the largest collection of middleware. It is the safest default for a team that wants to move quickly.
Choose Koa when you want a smaller core and the onion model. Async error handling is cleaner, the context object removes a lot of req/res juggling, and you pay for only the middleware you add. The trade is a smaller ecosystem and more assembly on your part.
Testing a Koa app
Because a Koa app is a middleware pipeline, it is straightforward to test with supertest. Export the app and pass app.callback() to the request helper so no port is opened.
import request from "supertest";
import app from "../app.js";
test("GET /posts returns a list", async () => {
const res = await request(app.callback()).get("/posts").expect(200);
expect(Array.isArray(res.body)).toBe(true);
});
As with Express, keep the app definition separate from app.listen() so tests can import it without starting a server.
Best practices
- Keep the error-handling middleware first so it wraps every other layer.
- Use
ctx.statefor request-scoped values such as the authenticated user. - Set
ctx.bodyandctx.statusrather than touching the raw response. - Register
bodyParserbefore the router soctx.request.bodyis populated. - Use
ctx.throwfor expected HTTP errors and a single handler for the rest. - Always subscribe to
app.on("error")to log failures that happen after headers. - Export the app separately from the server so tests stay fast.
Common mistakes
- Calling
next()twice in one middleware and running downstream code again. - Forgetting
awaitbeforenext(), which skips the “way out” half of the onion. - Registering the router before the body parser and finding
ctx.request.bodyempty. - Assuming Koa has a router or body parser built in and importing the wrong package.
- Swallowing errors without emitting them, leaving nothing in the logs.
- Mutating
ctx.resdirectly and bypassing Koa’s status and header handling.
Where to go next
Koa is the cleanest demonstration of async middleware in Node, and its onion model shows up in frameworks across every language. If you want a larger built-in feature set and more speed, read the Fastify guide. If you prefer the framework Koa grew out of, revisit Express. To understand the HTTP layer underneath ctx, start with the HTTP guide, and keep the Node.js basics close by.