Error Handling

Consistent JSON Error Shape with ctx.throw and a Central Handler

Error Handling

Build a central Koa error-handling middleware that catches every thrown error and returns a consistent JSON error envelope, covering validation, not-found, and unexpected server errors.

4 min read Level 2/5 #koa#error-handling#rest
What you'll learn
  • Use ctx.throw() to throw HTTP errors with a status code and message
  • Write a top-of-stack error middleware that serialises errors to JSON
  • Distinguish between operational errors (4xx) and programmer errors (5xx)

Without a central error handler, each route must contain its own try/catch and decide how to format the error response. A single top-level middleware handles every thrown error in one place and guarantees a consistent JSON shape.

ctx.throw()

ctx.throw(status, message, properties?) throws an error that Koa catches and turns into an HTTP response. Use it anywhere in a middleware or controller:

export async function getArticle(ctx) {
  const article = await ArticleService.findById(ctx.params.id);
  if (!article) ctx.throw(404, "Article not found");
  ctx.body = article;
}

Pass a third argument to attach extra data — validation errors for example:

ctx.throw(422, "Validation failed", {
  errors: { title: ["Required"] },
});

Central Error Middleware

Register this middleware first (before the router) so it wraps every subsequent middleware:

// src/middleware/errorHandler.js
export async function errorHandler(ctx, next) {
  try {
    await next();
  } catch (err) {
    const status  = err.status || err.statusCode || 500;
    const expose  = status < 500;               // safe to show message to client

    ctx.status = status;
    ctx.body   = {
      error: {
        status,
        message: expose ? err.message : "Internal Server Error",
        ...(err.errors && { errors: err.errors }),
      },
    };

    // re-emit 5xx so app's 'error' listener can log/alert
    if (status >= 500) ctx.app.emit("error", err, ctx);
  }
}

Mount it at the very top of the middleware stack:

import Koa from "koa";
import { errorHandler } from "./middleware/errorHandler.js";
import bodyParser from "koa-bodyparser";
import articlesRouter from "./routes/articles.js";

const app = new Koa();

app.on("error", (err, ctx) => {
  console.error("[unhandled]", err);           // log or send to Sentry
});

app.use(errorHandler);                         // ← must be first
app.use(bodyParser());
app.use(articlesRouter.routes());
app.use(articlesRouter.allowedMethods());

app.listen(3000);

Consistent Error Shape

All error responses now share the same envelope:

// 404
{ "error": { "status": 404, "message": "Article not found" } }

// 422 with field errors
{ "error": { "status": 422, "message": "Validation failed",
             "errors": { "title": ["Required"] } } }

// 500 (message hidden from client)
{ "error": { "status": 500, "message": "Internal Server Error" } }

404 for Unknown Routes

Add a catch-all after all routers to return a proper 404 instead of Koa’s default empty response:

app.use((ctx) => {
  ctx.throw(404, `Route ${ctx.method} ${ctx.path} not found`);
});

Up Next

Document your API with an OpenAPI spec so consumers know exactly what to expect.

OpenAPI Documentation →