Async Flow and Error Handling

try/catch Around await next() Replaces Four-Argument Express Middleware

Async Flow and Error Handling

Koa's async middleware stack means errors propagate automatically through the promise chain. A single try/catch around await next() handles every error thrown downstream — no callback hell, no forgotten next(err) calls.

4 min read Level 2/5 #koa#async#await
What you'll learn
  • Catch errors from any downstream middleware with a single try/catch
  • Understand why async errors are safer in Koa than in Express
  • Write a top-level error-handling middleware for consistent JSON error responses

Koa’s middleware stack is built on promises. Every middleware function returns a promise, and await next() chains them together. This has one powerful consequence: a try/catch block around await next() catches any error thrown anywhere downstream — synchronous or asynchronous.

The Problem With Express Callbacks

In Express, an async error that is not explicitly caught and passed to next is either silently swallowed or crashes the process:

// Express — easy to forget next(err)
app.get("/user", async (req, res, next) => {
  try {
    const user = await db.find(req.params.id); // throws
    res.json(user);
  } catch (err) {
    next(err); // you MUST remember this
  }
});

The Koa Way — Try/Catch Around await next()

In Koa you write one error-handling middleware at the top of the stack. It wraps every request in a single try/catch:

import Koa from "koa";

const app = new Koa();

// Top-level error handler
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status ?? 500;
    ctx.body = {
      error: err.message || "Internal Server Error",
    };
    ctx.app.emit("error", err, ctx); // also notify the app error event
  }
});

// This middleware throws — the error is caught above automatically
app.use(async (ctx) => {
  const data = await fetchDataThatMightFail(); // throws
  ctx.body = data;
});

app.listen(3000);

No matter how deep in the stack an error occurs — or whether it is thrown from an awaited promise — it travels up the promise chain and is caught by the outer try/catch.

ctx.throw vs Thrown Errors

ctx.throw creates an HttpError with a status code attached. The error handler can read err.status to decide the response code:

app.use(async (ctx) => {
  if (!ctx.query.id) {
    ctx.throw(400, "id is required");
  }
});

Regular throw new Error("...") will result in a 500 unless you set err.status or err.statusCode before throwing.

No More Callback Hell

Because each middleware is simply an async function, you compose them with plain await — no pyramid of callbacks and no risk of calling next twice or forgetting it inside a branching code path.

Up Next

Apply what you have learned by structuring a real Koa application across multiple files.

Application Structure →