Koa Interview Questions and Answers

Last updated:

Check out 30 of the most common Koa interview questions, then take an AI-powered practice interview

Node.jsJavaScriptasync/awaitMiddlewareExpress alternative
30+
Questions
12
Basic
13
Intermediate
5
Advanced
Q1

What is Koa and how is it different from Express?

BasicFundamentals

Answer

Koa is a Node.js web framework created in 2013 by the original Express team. It was designed as Express's spiritual successor after async/await became viable, the whole framework is built around composable async functions instead of callback-style middleware. The core differences are: (1) Koa has no built-in router, body parser, or template engine, you assemble what you need. (2) Every middleware receives a single `ctx` object that wraps both request and response, instead of `req, res`. (3) Middleware runs in a downstream-then-upstream pattern using `await next()`, which makes operations like 'measure how long a request took' trivial. (4) Errors thrown anywhere in the middleware chain are caught by the framework and emit a single `error` event, no `next(err)` plumbing.

The trade-off: Koa is leaner and more explicit but you build more yourself, whereas Express ships more out of the box and has a larger plugin ecosystem. In an interview, an extra credit answer is to mention that Koa's design has influenced multiple frameworks since, Hapi adopted similar patterns, Fastify took inspiration for its lifecycle hooks, and Python's Starlette/FastAPI use the same onion-model middleware approach.

Key Points

  • Built by the original Express team, same DNA, modern design
  • Single ctx object instead of req/res
  • Async/await first-class via downstream/upstream middleware
  • No built-in router or body parser, fully opt-in
Q2

How do you create a minimal Koa application?

BasicSetup

Answer

After `npm install koa`, you instantiate `new Koa()`, register middleware with `app.use()`, and call `app.listen()`. The simplest middleware sets `ctx.body` to whatever you want returned. Koa serialises objects to JSON automatically and sets the right Content-Type header, strings become text/plain, Buffers become application/octet-stream, and Node streams are piped to the response with proper backpressure.

There is no router built in, for production work you almost always pair Koa with `@koa/router`. Note that Koa exports a class (capital K) via `require('koa')` in CommonJS or `import Koa from 'koa'` in ESM, depending on your Node.js setup. The minimum supported Node version for Koa 2.x is Node 12, and Koa 3.x (released 2024) requires Node 18+ and ships as pure ESM, pay attention to which major version your team is on, because the migration involves real work around CommonJS interop.

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
  ctx.body = { hello: 'world' };
});

app.listen(3000, () => {
  console.log('Koa server running on http://localhost:3000');
});

Key Points

  • new Koa() to instantiate
  • ctx.body sets the response
  • JSON serialisation is automatic
💡 Pro Tip: If you see `TypeError: Koa is not a constructor` after upgrading to Koa 3.x, you've hit the ESM transition, Koa 3 is pure ESM. Convert your project to ESM or pin to Koa 2.x until you migrate.
Q3

What is the `ctx` (context) object in Koa?

BasicContext

Answer

`ctx` is the single object passed to every Koa middleware. It encapsulates the Node.js IncomingMessage and ServerResponse along with Koa-specific helpers. Inside `ctx` you'll find: `ctx.request` and `ctx.response` (Koa's enhanced wrappers), `ctx.req` and `ctx.res` (the raw Node objects, used rarely), and aliased shortcuts like `ctx.body`, `ctx.status`, `ctx.headers`, `ctx.method`, `ctx.path`, `ctx.query`.

A new ctx is created for every incoming request and lives only for that request's lifetime, so you can safely attach per-request state to it (e.g. `ctx.state.user = currentUser`). The unified shape is a deliberate ergonomic choice, it removes the constant `req, res` repetition that defines Express handlers and makes middleware composition cleaner. Under the hood, ctx is created by Koa via prototype-based delegation: `app.context` is the prototype, and ctx, request, and response are all instances that inherit from their respective prototypes. This is why you can extend the context globally by mutating `app.context`, useful for adding helper methods like `ctx.success(data)` that you want available everywhere.

app.use(async (ctx, next) => {
  // ctx.request, Koa-enhanced request
  console.log(ctx.method, ctx.path, ctx.query);

  // ctx.state, per-request scratch space
  ctx.state.requestId = crypto.randomUUID();

  // ctx.req / ctx.res, raw Node objects (rarely needed)
  console.log(ctx.req.socket.remoteAddress);

  // Helpers
  if (ctx.is('json')) { /* request was JSON */ }
  ctx.accepts('html', 'json'); // content negotiation

  await next();
});

Key Points

  • Single object per request, created and destroyed per request
  • Wraps both request and response
  • ctx.state for per-request data, app.context for shared resources
  • ctx.req/ctx.res are the raw Node objects
Q4

How does middleware work in Koa?

BasicMiddleware

Answer

Koa middleware is any async function with the signature `(ctx, next) => Promise<void>`. When a request comes in, Koa runs the middleware stack like a stack of layers: each middleware does its 'downstream' work, calls `await next()` to defer to the next middleware, and then continues its 'upstream' work after the rest of the stack has finished. This pattern is called the onion model.

Skipping `await next()` short-circuits the chain, useful when you want to bail out (e.g. a 401 from an auth middleware). The same `ctx` is threaded through every middleware, so anything stored on it earlier is visible later. Order matters enormously: a logger middleware mounted first sees every request, an auth middleware mounted before the router protects everything that follows, and a static-file middleware placed before the router serves files without ever hitting your API logic. Forgetting the `await` keyword before `next()` is the single most common Koa beginner bug, the chain still appears to run, but errors aren't caught and timing wraparound logic measures the wrong thing.

app.use(async (ctx, next) => {
  const start = Date.now();
  await next(); // wait for downstream middleware to finish
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

app.use(async (ctx) => {
  ctx.body = 'Hello';
});

Key Points

  • Async functions with signature (ctx, next) => Promise<void>
  • Call `await next()` to defer to the next middleware
  • Onion model: downstream work, then upstream work after next()
  • Order matters, logger first, auth before router, etc.
  • Forgetting `await` is the #1 bug, errors swallowed silently
💡 Pro Tip: Forgetting `await` before `next()` is the #1 Koa beginner bug, the chain runs but errors swallow silently.
Q5

Explain the onion model in Koa middleware.

BasicMiddleware

Answer

The onion model is Koa's term for how middleware executes. Imagine each middleware as a layer of an onion: the request enters from outside, passes through every layer downward (downstream), reaches the centre (the route handler), then unwinds back out (upstream). In code, the downstream work is everything BEFORE `await next()`, and the upstream work is everything AFTER it.

This is profoundly different from Express, where middleware is more like a pipeline that mostly flows in one direction. The onion model is what makes timing, error wrapping, response transformation, and compression middleware so clean, you can wrap downstream logic without callbacks. The same idea exists in Python ASGI (Starlette/FastAPI) and was inspired in part by Koa.

A concrete example: a 'timing' middleware records `Date.now()` before `await next()` and writes an `X-Response-Time` header after, without onion semantics you'd have to hook into an `on('finish')` event manually. The cost of this model is that you cannot 'continue past' an error like Express's error-handling middleware does, if a downstream middleware throws, all upstream code after `await next()` in the layers above is skipped. That's almost always what you want, but it surprises Express veterans who expect to handle errors in the middle of the chain.

app.use(async (ctx, next) => {
  console.log('1 downstream');
  await next();
  console.log('1 upstream');
});

app.use(async (ctx, next) => {
  console.log('2 downstream');
  await next();
  console.log('2 upstream');
});

app.use(async (ctx) => {
  console.log('3 middle');
  ctx.body = 'hello';
});

// Output for one request:
// 1 downstream
// 2 downstream
// 3 middle
// 2 upstream
// 1 upstream

Key Points

  • Downstream: before await next()
  • Upstream: after await next()
  • Enables wrap-around logic (timing, compression, error handling)
  • Modelled after function composition, not a pipeline
Q6

How do you handle routing in Koa?

BasicRouting

Answer

Koa intentionally has no built-in router, you install `@koa/router` (the official routing package). You create a router instance, attach handlers with `router.get/post/put/del`, and then mount it with `app.use(router.routes()).use(router.allowedMethods())`. The router auto-generates 405 Method Not Allowed and 501 Not Implemented responses when you include `allowedMethods()`, which is good production hygiene.

Routes support standard path parameters (`/users/:id`) accessible via `ctx.params.id`, prefixes via `new Router({ prefix: '/api/v1' })`, and nesting via `router.use('/admin', adminRouter.routes())`. The official package was renamed from `koa-router` to `@koa/router` in 2018, older tutorials still reference the unscoped name, but the scoped version is the actively maintained one. Routes can also have per-route middleware: `router.get('/admin/users', requireAdmin, listUsers)` runs `requireAdmin` before `listUsers`, which is how most Koa codebases compose authorisation checks at the route level instead of via global middleware.

const Router = require('@koa/router');
const router = new Router({ prefix: '/api/v1' });

router.get('/users/:id', async (ctx) => {
  const user = await db.users.findById(ctx.params.id);
  if (!user) ctx.throw(404, 'User not found');
  ctx.body = user;
});

// Per-route middleware (e.g. authorisation)
router.post('/admin/users', requireAdmin, async (ctx) => {
  ctx.body = await db.users.create(ctx.request.body);
});

app.use(router.routes()).use(router.allowedMethods());

Key Points

  • Install @koa/router (scoped, official), not unscoped koa-router
  • Use allowedMethods() for auto 405/501 responses
  • Path params via ctx.params, prefixes via new Router({ prefix })
  • Per-route middleware: router.get(path, mw1, mw2, handler)
💡 Pro Tip: If you install the wrong package, `koa-router` instead of `@koa/router`, your code will still work but you'll miss bug fixes and security patches. Always install the scoped package.
Q7

How do you parse request bodies in Koa?

BasicBody Parsing

Answer

Koa does not parse request bodies by default. Add `koa-bodyparser` (or `@koa/bodyparser` in newer setups) as middleware before your routes. It populates `ctx.request.body` with the parsed JSON, form, or text payload based on Content-Type.

For multipart/form-data (file uploads) you need `@koa/multer` or `koa-multer`, which is a Koa wrapper around the Express Multer middleware. Configure body size limits (`jsonLimit`, `formLimit`) to prevent denial-of-service from huge payloads, defaults are 1mb which is sane for most APIs. A common gotcha: `ctx.request.body` (with the parser) is different from `ctx.body` (which is the response body setter).

Mixing them up is a frequent source of confusion when migrating from Express. Note that `koa-bodyparser` also exposes `ctx.request.rawBody`, useful for webhook signature verification where you need the unmodified bytes to compute an HMAC.

const bodyParser = require('koa-bodyparser');

app.use(bodyParser({
  jsonLimit: '1mb',
  enableTypes: ['json', 'form'],
}));

router.post('/users', async (ctx) => {
  const { email, name } = ctx.request.body; // parsed JSON
  const user = await db.users.create({ email, name });
  ctx.status = 201;
  ctx.body = user;
});

Key Points

  • Koa doesn't parse bodies by default, add koa-bodyparser
  • ctx.request.body is the parsed body; ctx.body is the response
  • Set jsonLimit/formLimit to prevent DoS via huge payloads
  • Use ctx.request.rawBody for webhook signature verification
💡 Pro Tip: Configure `koa-bodyparser` to skip parsing for routes that need raw bytes (webhooks). The `disableBodyParser` middleware pattern lets specific routes opt out so signature verification works.
Q8

What's the difference between `ctx.body`, `ctx.response.body`, and `ctx.res`?

BasicContext

Answer

`ctx.body` is a shortcut/alias for `ctx.response.body`, they refer to the same underlying setter on the Koa Response object. Both go through Koa's response handling: type detection (string vs JSON vs stream vs Buffer), automatic Content-Type, and status code defaulting. `ctx.res` is the raw Node.js ServerResponse, bypassing it skips Koa's logic entirely. The rule: always set `ctx.body` (or `ctx.response.body`).

Touch `ctx.res` only when you absolutely need low-level control like writing streams without a Content-Length, and even then prefer Koa's stream support by setting `ctx.body = stream`. A subtle but important detail: setting `ctx.body = null` produces a 204 No Content response (with no body), while not setting it at all means Koa returns 404 if no other middleware handled the request. This is by design and lets you express 'I handled this but there's nothing to return' cleanly without manually setting status.

// Recommended
ctx.body = { ok: true };
ctx.body = 'hello world';
ctx.body = fs.createReadStream('big.csv');
ctx.body = Buffer.from('raw bytes');
ctx.body = null; // -> 204 No Content

// Equivalent, these refer to the same setter
ctx.response.body = { ok: true };

// Avoid unless you really need raw Node control
ctx.res.writeHead(200);
ctx.res.end('low-level');

Key Points

  • ctx.body === ctx.response.body (alias)
  • Both go through Koa's response pipeline
  • ctx.res is raw Node, avoid unless necessary
  • Koa auto-handles JSON, strings, streams, and Buffers
Q9

How do you set HTTP status codes and headers in Koa?

BasicResponses

Answer

Set status via `ctx.status = 201`. Koa defaults to 200 if `ctx.body` is set, 204 if not, so you rarely need to set 200 explicitly. For response headers use `ctx.set('Header-Name', 'value')` or `ctx.set({ 'X-A': '1', 'X-B': '2' })` for multiple.

Read incoming headers with `ctx.get('User-Agent')` (case-insensitive). For cookies, use `ctx.cookies.set('name', 'value', { httpOnly: true, maxAge: 3600000 })` and `ctx.cookies.get('name')`, Koa's cookie helper supports signed cookies if you set `app.keys = ['secret-key']`. There's also `ctx.append('Set-Cookie', '...')` for response headers that can appear multiple times.

Note that header names are normalised to lowercase internally, both `ctx.set('X-Foo', 'bar')` and `ctx.set('x-foo', 'bar')` produce the same output. For setting Content-Type explicitly use `ctx.type = 'text/html'` rather than `ctx.set('Content-Type', ...)`, Koa adds the correct charset and uses MIME shortcuts (`ctx.type = 'json'` becomes `application/json; charset=utf-8`).

router.post('/login', async (ctx) => {
  const token = await authService.login(ctx.request.body);
  ctx.status = 200;
  ctx.set('X-Request-ID', ctx.state.requestId);
  ctx.cookies.set('refresh', token.refresh, { httpOnly: true, maxAge: 7 * 86400000 });
  ctx.body = { accessToken: token.access };
});

Key Points

  • ctx.status = number sets the response status
  • Defaults: 200 when ctx.body is set, 204 when not, 404 for unhandled
  • ctx.set(name, value) for headers; ctx.append for multi-value
  • ctx.cookies.set/.get with httpOnly/secure flags in production
💡 Pro Tip: Set `app.proxy = true` if you're behind a proxy/load balancer, without this, `ctx.ip`, `ctx.host`, and `ctx.protocol` reflect the proxy instead of the client. Critical for accurate logging and rate-limit keys.
Q10

How do you serve static files in Koa?

BasicStatic Files

Answer

Use the `koa-static` middleware. Point it at a directory and any request whose path matches a file in that directory is served, with proper ETag, Last-Modified, and gzip support. Place it before your router so static files are served first without hitting the API layer.

In production, you'd typically front Koa with nginx or a CDN (Cloudflare, Fastly) and let them serve assets, `koa-static` is fine for local dev, internal tools, or low-traffic admin UIs but burns Node event-loop time on bytes you don't need to push. If you need SPA routing (fallback to index.html for unmatched paths), use `koa-static` for the static directory plus a catch-all route at the end that returns `index.html`. For Indian production setups, most teams put Cloudflare in front and serve assets from R2 or S3 with a long max-age, Koa never sees an asset request once the CDN warms up, which is what you want.

const serve = require('koa-static');
const path = require('path');

app.use(serve(path.join(__dirname, 'public'), {
  maxage: 24 * 60 * 60 * 1000, // 1 day browser cache
  gzip: true,
}));

Key Points

  • Use koa-static, handles ETag, Last-Modified, gzip
  • Mount before your router so files served without hitting API logic
  • In production, front with nginx or a CDN, Koa shouldn't push asset bytes
  • For SPA routing, add a catch-all that returns index.html
Q11

What is `ctx.throw()` and how does it work?

BasicError Handling

Answer

`ctx.throw(status, message)` is Koa's shortcut for raising an HTTP error. It throws a JavaScript Error with `.status` and `.expose = true` set, which Koa's default error handler picks up and converts to an HTTP response with the correct status code. Because it throws, all upstream middleware after `await next()` is skipped, the error bubbles up to Koa's central error catcher.

You can pass a third argument for additional properties: `ctx.throw(400, 'Invalid email', { code: 'INVALID_EMAIL' })`. For programmatic use, the `http-errors` package gives you the same behaviour via `throw new createError.BadRequest('...')`. The `expose` flag matters for security: 4xx errors are exposed by default (the message goes to the client) but 5xx errors are not, so `ctx.throw(500, 'DB password is wrong')` correctly returns 'Internal Server Error' to the client while preserving your error message in logs. You can override this by passing `expose: true` explicitly, but you almost never want to expose 5xx messages because they might leak internal details.

router.get('/users/:id', async (ctx) => {
  const user = await db.users.findById(ctx.params.id);
  if (!user) ctx.throw(404, 'User not found');
  // unreachable if user doesn't exist
  ctx.body = user;
});

Key Points

  • Throws an Error with .status and .expose populated
  • 4xx errors expose the message to clients; 5xx errors hide it
  • Skips all upstream middleware after await next()
  • Top-level error handler picks up the result
  • Third argument adds custom properties (code, requestId, fields)
💡 Pro Tip: If exposing the error message to clients is unsafe, use `ctx.throw(500)` with no message, Koa returns 'Internal Server Error' instead of leaking details.
Q12

How do you enable CORS in a Koa application?

BasicCORS

Answer

Use the `@koa/cors` middleware. Mount it before your router so CORS preflight (OPTIONS) requests are handled before they hit your route logic. For development, allow all origins with `cors()`; for production, restrict to your known frontend hosts and disable wildcard with credentials.

As with FastAPI/Express, never combine `origin: '*'` with `credentials: true`, browsers reject it. A common Indian-startup pattern is to read allowed origins from an env var (a comma-separated list) so production and staging configs differ without code changes. CORS bugs typically manifest as 'Access-Control-Allow-Origin missing' errors in the browser console, when you see that, check three things in order: (1) is the middleware mounted before the router?, (2) does the origin actually match the configured allow-list?, (3) is there a reverse proxy (nginx) stripping CORS headers? Item 3 is the silent killer, nginx with the wrong `proxy_pass` config drops `Access-Control-*` headers from upstream responses.

const cors = require('@koa/cors');

app.use(cors({
  origin: (ctx) => {
    const allowed = process.env.ALLOWED_ORIGINS.split(',');
    return allowed.includes(ctx.request.header.origin) ? ctx.request.header.origin : '';
  },
  credentials: true,
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
}));

Key Points

  • Mount @koa/cors before your router and error handler
  • Never use origin: '*' with credentials: true
  • Read allowed origins from env for dev/staging/prod differences
  • CORS errors are visible in browser dev tools, not server logs
💡 Pro Tip: If your error-handling middleware short-circuits before `await next()`, CORS headers won't be applied to the response. Mount `@koa/cors` BEFORE your error handler if you want CORS headers on error responses too.
Q13

How do you handle errors globally in Koa?

IntermediateError Handling

Answer

Two complementary mechanisms. First, register an error-handling middleware as the very first `app.use()`, wrap everything in a try/catch and convert errors to the response you want. Second, attach an `error` event listener to the app to log uncaught errors (useful for emitting metrics or sending to Sentry).

The framework's default behaviour is sensible: it returns the error's `.status` (or 500 if none) and only exposes `.message` when `expose` is true (which `ctx.throw()` sets automatically). Don't try to do error handling inside individual routes, let exceptions bubble to the top-level middleware where you can apply a consistent shape (e.g. `{ error: { code, message, requestId } }`). For domain-specific errors (e.g. `OutOfStockError`, `InsufficientBalanceError`), define them as classes that extend Error and check their type in the top-level handler to produce the right HTTP status, this keeps your route handlers focused on business logic while the centralised handler manages the HTTP translation. Sentry/Bugsnag/Datadog integrations are typically wired into the `app.on('error')` listener so they capture everything without each route needing special code.

// First middleware, catches everything downstream
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status || 500;
    ctx.body = {
      error: {
        code: err.code || 'INTERNAL_ERROR',
        message: err.expose ? err.message : 'Something went wrong',
        requestId: ctx.state.requestId,
      },
    };
    ctx.app.emit('error', err, ctx);
  }
});

app.on('error', (err, ctx) => {
  logger.error({ err, path: ctx?.path, requestId: ctx?.state?.requestId }, 'Unhandled error');
});

Key Points

  • Top-level try/catch middleware is the canonical pattern
  • Listen to 'error' event for logging/metrics
  • Respect err.expose to avoid leaking internals
  • Emit ctx.app.emit('error', err, ctx) so handlers see it
💡 Pro Tip: Include the request ID in the error response (e.g. `{ error: { ..., requestId } }`) so when a user reports a bug you can grep your logs by that ID and trace the exact request that failed, saves hours of debugging.
Q14

How do you implement JWT authentication in Koa?

IntermediateAuthentication

Answer

Use `jsonwebtoken` (or `jose` for stricter standards compliance) to sign and verify tokens, and either `koa-jwt` middleware or a hand-written verification middleware. The pattern: a `/login` route validates credentials and returns a signed JWT; an auth middleware reads the `Authorization: Bearer <token>` header, verifies the signature, decodes the payload, and stores it on `ctx.state.user`. Mount the auth middleware before any protected routes and use `koa-jwt`'s `unless` option (via `koa-unless`) to exempt public endpoints.

Production rules: HS256 with a 256-bit secret OR RS256 with a key pair, short-lived access tokens (15 min), refresh tokens in HttpOnly cookies, secret loaded from a secrets manager (Infisical, AWS Secrets Manager), never from `.env` files in prod. For role-based access control, encode roles or permission flags in the JWT and check them in a second middleware (`requireRole('admin')`), this keeps the JWT verification middleware single-purpose and lets you compose authorisation checks per route. Beware the classic JWT mistakes: never put secrets in the payload (it's base64, not encrypted), never trust the `alg` field from the token, and rotate signing keys at least once a quarter (with grace periods using the `kid` header).

const jwt = require('jsonwebtoken');
const koaJwt = require('koa-jwt');

const SECRET = process.env.JWT_SECRET;

router.post('/login', async (ctx) => {
  const user = await authService.verify(ctx.request.body);
  const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: '15m' });
  ctx.body = { token };
});

// Protect everything except /login and /health
app.use(koaJwt({ secret: SECRET }).unless({ path: [/^\/login/, '/health'] }));

router.get('/me', async (ctx) => {
  ctx.body = { userId: ctx.state.user.sub };
});

Key Points

  • koa-jwt + unless() for clean route protection
  • Decoded payload lands on ctx.state.user automatically
  • Short-lived access tokens, HttpOnly refresh cookies
  • Secret from secrets manager, never hard-coded
💡 Pro Tip: Never trust `req.user` blindly, re-fetch the user from the DB at sensitive endpoints (payments, password changes) because the JWT may have been issued before the account was suspended or downgraded. JWTs are stateless, but authorisation often needs fresh state.
Q15

How do you connect Koa to a database (Postgres/Mongo)?

IntermediateDatabase

Answer

There is no Koa-specific way, you use whatever Node.js driver or ORM you like and inject the client into middleware via `ctx.state` or a service container. For Postgres: `pg` directly, or `prisma`, or `knex` for query building. For Mongo: the official `mongodb` driver, or `mongoose`.

Open a connection pool at app startup, not per request. A common pattern is to attach the DB instance to `ctx` so handlers don't need to import it. Production rules: pool size matches your worker concurrency (10-20 connections per worker is typical), use prepared statements / parameterised queries, and always handle reconnection, the `pg.Pool` and `mongoose.connect` defaults are generally good but log when a connection errors.

Transactions need extra care in async middleware: open the transaction in the route handler, not in a middleware, because middleware aren't easily composable around transaction lifecycle. If you DO want middleware-level transactions (e.g. wrap every mutating endpoint), pass a callback into the route handler that gets a transaction-bound client; the alternative, storing a transaction on `ctx.state.tx` and committing in upstream middleware, is fragile because exceptions during commit are awkward to surface.

const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
});

app.context.db = pool; // attach once, available on every ctx

router.get('/users/:id', async (ctx) => {
  const { rows } = await ctx.db.query('SELECT id, email FROM users WHERE id = $1', [ctx.params.id]);
  if (!rows[0]) ctx.throw(404);
  ctx.body = rows[0];
});

Key Points

  • Open a pool at startup, attach to app.context (not ctx.state)
  • Pool size 10-20 per worker, match your concurrency
  • Always use parameterised queries, never string interpolation
  • Set acquire timeouts to fail fast on unresponsive DB
💡 Pro Tip: Attaching to `app.context` (not `ctx.state`) means the property is on the prototype chain and shared, saves a tiny allocation per request.
Q16

How do you handle sessions in Koa?

IntermediateSessions

Answer

Use `koa-session` for cookie-based sessions (data lives in a signed cookie) or `koa-session` with a store like Redis or MongoDB for server-side sessions. Cookie-based is simpler but limits data to ~4kb and is visible (signed but not encrypted by default). Redis-backed sessions are the production standard, sessions are small (just a session ID in the cookie) and you can invalidate on logout, list active sessions per user, and scale horizontally.

Set `app.keys` to a list of secrets used to sign the cookie, and configure secure cookie flags in production (`secure: true`, `sameSite: 'lax'`, `httpOnly: true`). In India most B2C apps prefer JWTs over sessions because they pair better with mobile clients, but server-rendered admin panels still benefit from sessions. Why `app.keys` is an ARRAY: it supports key rotation.

Add a new key to the front, and old cookies signed with the previous key still validate during a grace period. When you remove the old key, those sessions are invalidated, gives you a clean rotation story without forcing every user to log out. For CSRF protection in session-based flows, pair `koa-session` with `koa-csrf` and add the token to your forms; for pure-API flows JWT in Authorization headers is naturally CSRF-resistant.

const session = require('koa-session');
const redisStore = require('koa-redis');

app.keys = [process.env.SESSION_SECRET];
app.use(session({
  store: redisStore({ url: process.env.REDIS_URL }),
  maxAge: 86400 * 1000 * 7, // 7 days
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
}, app));

router.post('/cart/add', async (ctx) => {
  const items = ctx.session.items || [];
  items.push(ctx.request.body);
  ctx.session.items = items;
  ctx.body = { items };
});

Key Points

  • koa-session, cookie-based (simple) or Redis-backed (production)
  • app.keys is an ARRAY for rotation support
  • httpOnly + secure + sameSite cookie flags in prod
  • In India, B2C apps prefer JWTs; admin panels prefer sessions
💡 Pro Tip: If you switch from cookie-based to Redis-based sessions, the session cookie schema changes, existing logged-in users get logged out at the moment of deploy. Either accept the one-time logout or run a migration that re-issues sessions for live users.
Q17

How do you write integration tests for Koa endpoints?

IntermediateTesting

Answer

Use `supertest` to make HTTP requests against the Koa app callback (`app.callback()`) without starting a real server. Pair it with `jest`, `mocha`, or `vitest`. The trick: `app.callback()` returns a function compatible with Node's `http.createServer`, which is what supertest needs.

You can also start the server with `app.listen()` and supertest will close it after. For tests that need to swap dependencies (DB, external HTTP clients), use a service container pattern or `app.context.db = fakeDb` in a beforeEach, much easier than mocking modules. Run tests in transactions where possible (`pg-mem`, `mongodb-memory-server`) so each test is isolated and fast.

For testing middleware in isolation (without the full app), build a tiny `(ctx, next) => ...` invocation manually with a mock ctx object, this is the unit-test equivalent and runs faster than spinning up the whole app. CI-wise, most Indian teams run tests with `jest --runInBand` for stability and combine it with `nyc` or `c8` for coverage; aim for 80%+ branch coverage on business logic, lower bar on glue code.

const request = require('supertest');
const { createApp } = require('../src/app');

describe('GET /users/:id', () => {
  let app;
  beforeEach(() => {
    app = createApp({ db: fakeDb });
  });

  it('returns 200 for an existing user', async () => {
    fakeDb.seed({ users: [{ id: 1, email: 'a@b.c' }] });
    const res = await request(app.callback()).get('/users/1');
    expect(res.status).toBe(200);
    expect(res.body.email).toBe('a@b.c');
  });

  it('returns 404 for a missing user', async () => {
    const res = await request(app.callback()).get('/users/999');
    expect(res.status).toBe(404);
  });
});
💡 Pro Tip: Test the failure modes too, 404s, validation 400s, auth 401s. Coverage tools count untested branches as a risk surface, and uncovered error paths are where real bugs hide.
Q18

What is the difference between `ctx.state` and `app.context` for sharing data?

IntermediateContext

Answer

`ctx.state` is a per-request namespace explicitly designed for passing data between middleware (e.g. `ctx.state.user`, `ctx.state.requestId`). A new state object is created on every request, so there's no risk of one request leaking into another. `app.context` is the prototype that ALL ctx objects inherit from, setting `app.context.db = pool` adds a property accessible as `ctx.db` on every request, but it's the SAME reference everywhere. Use `ctx.state` for request-specific data (authenticated user, validated body, trace ID).

Use `app.context` for shared resources that never vary per request (DB pool, Redis client, config). Don't mutate `app.context` during request handling, that would cross requests, which is the opposite of what you want. A real-world example of confusing the two: a developer wanted to share the authenticated user between middleware and put it on `app.context.user`.

The first request set the user; the second request didn't go through auth (it was a public endpoint) but still saw the previous user because `app.context` is shared. The fix was trivial, move it to `ctx.state.user`, but the bug took hours to find because manual testing didn't reproduce it consistently. The rule of thumb: if it varies per request, use `ctx.state`; if it's set once at startup and never changes, `app.context` is fine.

Key Points

  • ctx.state, per-request scratch space, fresh each request
  • app.context, shared prototype, set once at startup
  • Use state for request-specific data, context for shared resources
  • Mutating app.context during a request leaks across requests
Q19

How do you implement rate limiting in Koa?

IntermediateRate Limiting

Answer

Koa doesn't ship rate limiting natively. Production options: (1) `koa-ratelimit` with Redis storage, simple to wire and handles distributed limits correctly. (2) Hand-written middleware around `ioredis` for fully custom logic (sliding window, leaky bucket). The cardinal rule: behind a load balancer you MUST use a shared store, in-memory limits don't work across multiple Node instances.

Decide the key carefully: limit by IP for unauthenticated endpoints, by user ID for authenticated endpoints, and by API key for B2B endpoints. In India, common defaults are 60 req/min/IP for anonymous, 600 req/min/user for authenticated, and aggressive sub-second limits (10/sec) on login/signup to slow down credential stuffing. Be aware of a subtle trap: if you're behind Cloudflare or an AWS ALB, `ctx.ip` will be the proxy's IP, not the client's.

Configure `app.proxy = true` so Koa trusts `X-Forwarded-For`, otherwise everyone gets the same rate-limit bucket, and one bursty user trips the limit for the whole app. Always test rate limits with a real client behind your proxy before launching, not just from localhost.

const ratelimit = require('koa-ratelimit');
const Redis = require('ioredis');
const db = new Redis(process.env.REDIS_URL);

app.use(ratelimit({
  driver: 'redis',
  db,
  duration: 60_000,           // 1 minute window
  max: 60,                    // 60 requests per window
  errorMessage: 'Slow down, too many requests',
  id: (ctx) => ctx.state.user?.sub || ctx.ip,
  headers: {
    remaining: 'Rate-Limit-Remaining',
    reset: 'Rate-Limit-Reset',
    total: 'Rate-Limit-Total',
  },
}));
💡 Pro Tip: Always return the `Retry-After` header with a 429 response, well-behaved clients (including most API SDKs) use it to back off automatically instead of hammering you. Without it, retried requests pile up exactly when you're already overloaded.
Q20

How do you stream large responses (downloads, CSV, video) in Koa?

IntermediateStreaming

Answer

Set `ctx.body` to a Node.js Readable stream, Koa pipes it to the response automatically. This is the cleanest way to send large files without loading them into memory: a 5GB CSV export becomes feasible because the row generator never holds more than a chunk at a time. Don't forget to set `Content-Type` and (for downloads) `Content-Disposition`.

Backpressure is handled by Node's stream API, slow clients won't blow up memory. For SSE (Server-Sent Events), use a `Readable` stream that pushes `data: ...\n\n` lines as events fire. Caveat: if you pipe a stream and the response errors halfway through, Koa's default behaviour is to destroy the stream and emit an error event, wire your logger to capture these.

A common production trap: nginx and many proxies buffer responses by default, so streamed responses appear to clients all at once at the end. For SSE specifically, set `X-Accel-Buffering: no` to disable nginx buffering. For large file downloads, set `Cache-Control: no-store` and the right `Content-Length` if you know it, clients can show a progress bar with a known length and revert to indeterminate spinners without. If you're behind Cloudflare and streaming, beware their 100-second timeout on free plans; long-running streams require Enterprise.

const { Readable } = require('stream');

router.get('/users.csv', async (ctx) => {
  const cursor = ctx.db.query(new QueryStream('SELECT id, email FROM users'));
  const csvStream = Readable.from((async function* () {
    yield 'id,email\n';
    for await (const row of cursor) {
      yield `${row.id},${row.email}\n`;
    }
  })());

  ctx.type = 'text/csv';
  ctx.set('Content-Disposition', 'attachment; filename="users.csv"');
  ctx.body = csvStream;
});
💡 Pro Tip: For streaming with backpressure, prefer `pipeline()` from `node:stream/promises` over manual `.pipe()` chains, it handles errors and cleanup automatically. Koa sets `ctx.body = stream` and pipes for you, but if you're doing transformations, `pipeline()` is safer.
Q21

How do you handle WebSocket connections alongside a Koa HTTP server?

IntermediateWebSockets

Answer

Koa doesn't speak WebSockets natively, and that's actually fine. You attach a WebSocket server (typically `ws` or `socket.io`) to the same HTTP server that Koa is listening on. Both speak HTTP at the connection level; WebSocket starts as an HTTP Upgrade request, so the same port handles both.

There are also Koa-specific helpers like `koa-websocket` that integrate WS into Koa's middleware chain, but for production most teams use bare `ws` or `socket.io` because the ecosystem (auth middleware, room support, reconnection) is richer. For multi-instance scaling (i.e. more than one Node process), you'll need a pub/sub layer (Redis adapter) so events broadcast across all instances. Authentication is the tricky part: WebSockets don't carry Authorization headers easily (browser EventSource and WebSocket APIs can't set them), so common patterns are: (1) pass a JWT as a query parameter on connect, validate in the connection handler, (2) use a short-lived 'WebSocket ticket' issued by a regular HTTP endpoint that you exchange on connect, (3) authenticate via cookies (works if you're same-origin).

Pattern 2 is the cleanest for security but adds an extra round-trip. Production-scale WebSocket fleets (e.g. live cricket scoreboards on big Indian apps) use sticky sessions at the load balancer to avoid cross-instance subscription state, and use Redis pub/sub for broadcasting matches across the fleet.

const http = require('http');
const { WebSocketServer } = require('ws');

const server = http.createServer(app.callback());
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  // Verify auth (e.g. parse cookie or query token)
  ws.on('message', (data) => {
    wss.clients.forEach((client) => {
      if (client !== ws && client.readyState === 1) client.send(data);
    });
  });
});

server.listen(3000);
💡 Pro Tip: WebSocket connections are long-lived, which means a deploy will sever every client. Plan for reconnection on the client side (exponential backoff) and consider draining WebSocket connections gracefully in your shutdown handler.
Q22

How do you compose middleware in Koa using `koa-compose`?

IntermediateMiddleware

Answer

`koa-compose` is the function Koa itself uses internally to chain middleware. It takes an array of middleware functions and returns a single middleware that executes them in onion-model order. You use it when you want to package multiple related middleware as one unit (e.g. an auth flow = parse cookie + verify token + load user).

The composed function still has the standard `(ctx, next)` signature so it slots back into `app.use()`. This is the basis for many Koa libraries, they ship a `factory()` that returns a composed middleware so consumers add a single line. The internal implementation is surprisingly small (under 30 lines), it's worth reading the `koa-compose` source if you're learning Koa, because it shows exactly how the onion model is built from plain promises. The same `dispatch(i)` recursive pattern appears in countless modern frameworks (Hono, Oak, Cloudflare Workers ITTY-router), Koa is where it became mainstream in JavaScript.

const compose = require('koa-compose');

function authStack({ jwtSecret, userLoader }) {
  const parseToken = async (ctx, next) => {
    const header = ctx.get('authorization');
    if (header?.startsWith('Bearer ')) ctx.state.token = header.slice(7);
    await next();
  };
  const verifyToken = async (ctx, next) => {
    if (!ctx.state.token) ctx.throw(401);
    ctx.state.claims = jwt.verify(ctx.state.token, jwtSecret);
    await next();
  };
  const loadUser = async (ctx, next) => {
    ctx.state.user = await userLoader(ctx.state.claims.sub);
    await next();
  };
  return compose([parseToken, verifyToken, loadUser]);
}

app.use(authStack({ jwtSecret: SECRET, userLoader: db.users.findById }));
💡 Pro Tip: Look at the `koa-compose` source, it's about 25 lines and is one of the most elegant pieces of JavaScript you'll read. Understanding it deeply will make you better at every async-heavy framework, not just Koa.
Q23

How do you implement file uploads (multipart/form-data) in Koa?

IntermediateFile Uploads

Answer

`koa-bodyparser` does NOT handle multipart, you need a dedicated package. Two options: `@koa/multer` (Koa wrapper around the Express Multer middleware, simple memory or disk storage) or `koa-body` (combined body+multipart parser, more options). For production uploads, never store files on the Node process's local disk, stream directly to S3-compatible storage (AWS S3, GCS, Cloudflare R2, MinIO).

Limit file size at the parser layer (`limits: { fileSize: 10 * 1024 * 1024 }`) so attackers can't DoS you with 10GB uploads. Validate file type by inspecting the actual bytes (e.g. `file-type` package), never trust the client-supplied Content-Type or extension. For large uploads (>50MB), prefer 'presigned URL uploads': your Koa endpoint returns a presigned S3 PUT URL, and the client uploads directly to S3 without proxying through Node.

This is how most Indian SaaS products handle resume uploads, product photos, video, etc., Node only stamps metadata in the database after the upload completes. The pattern keeps Node CPU and memory free for serving API requests instead of shovelling bytes.

const multer = require('@koa/multer');
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
});

router.post('/profile/avatar', upload.single('avatar'), async (ctx) => {
  const file = ctx.file; // { buffer, originalname, mimetype, size }
  const key = `avatars/${ctx.state.user.sub}-${Date.now()}.jpg`;
  await s3.putObject({ Bucket: 'my-bucket', Key: key, Body: file.buffer, ContentType: file.mimetype });
  ctx.body = { url: `https://cdn.example.com/${key}` };
});
💡 Pro Tip: Scan uploaded files for malware before serving them to other users, use ClamAV (open source) or a cloud service like VirusTotal. A user-uploaded malicious PDF served back to other users is one of the most common ways small apps get into trouble.
Q24

How does Koa handle async errors and unhandled promise rejections?

IntermediateError Handling

Answer

Because Koa middleware is async, errors are surfaced as rejected promises that the framework awaits and catches. If any middleware throws or returns a rejected promise, Koa's internal try/catch sends a response (default 500 with the error message) and emits an `error` event on the app. The crucial detail: if you forget `await` before `next()` or before an async call, the promise floats free, Koa can't catch what it doesn't await, and you get an UnhandledPromiseRejection.

In Node.js 15+ unhandled rejections terminate the process by default. Always `await` async work. For truly fire-and-forget tasks (analytics, non-critical logs), wrap them in `.catch(err => log(err))` to make the choice explicit.

Add `process.on('unhandledRejection', err => logger.fatal(err))` and `process.on('uncaughtException', err => { logger.fatal(err); process.exit(1); })` early in app startup, even with discipline, a forgotten `await` happens, and you want to know. PM2 and Kubernetes will automatically restart a process that exits, so a fast crash on uncaught exceptions is the safe default in production.

💡 Pro Tip: Add `process.on('unhandledRejection', err => logger.fatal(err))` early in app startup, even with discipline, a forgotten `await` happens, and you want to know.
Q25

How do you implement request validation in Koa?

IntermediateValidation

Answer

Koa has no built-in validation. Pick a schema library, `zod` (most popular in 2026 because of its TypeScript inference), `joi`, or `yup`, and write a small middleware factory that validates `ctx.request.body`, `ctx.query`, or `ctx.params` against a schema and throws 400 on failure. Storing the parsed/coerced result on `ctx.state` (e.g. `ctx.state.validated`) gives downstream handlers type-safe data without re-parsing.

This pattern is so common most teams end up writing their own version, or use `koa-zod-router`, which wraps `@koa/router` with built-in zod validation per route. Two production tips: (1) Always sanitise the error response, return a stable shape like `{ error: { code: 'VALIDATION', fields: [{ path: 'email', message: 'invalid' }] }}` so the frontend can render field-level errors without parsing English text. (2) Validate on the way IN, not at the database layer, defence in depth means schema validation in the API layer plus constraints in the DB. The cost is a tiny duplication but the safety is worth it.

const { z } = require('zod');

function validate(schemas) {
  return async (ctx, next) => {
    try {
      if (schemas.body) ctx.state.body = schemas.body.parse(ctx.request.body);
      if (schemas.query) ctx.state.query = schemas.query.parse(ctx.query);
      if (schemas.params) ctx.state.params = schemas.params.parse(ctx.params);
    } catch (err) {
      ctx.throw(400, 'Invalid input', { details: err.errors });
    }
    await next();
  };
}

const createUserBody = z.object({
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
});

router.post('/users', validate({ body: createUserBody }), async (ctx) => {
  const { email, age } = ctx.state.body; // validated and typed
  const user = await db.users.create({ email, age });
  ctx.status = 201;
  ctx.body = user;
});
💡 Pro Tip: When using zod, prefer `.safeParse()` over `.parse()` if you want to format errors yourself, `.parse()` throws, which means your error message format is whatever zod chooses. `.safeParse()` returns a result object you can transform into your standard API error shape.
Q26

How would you architect a Koa-based BFF (Backend for Frontend) at 5,000+ RPS?

AdvancedArchitecture

Answer

Koa is a great fit for a BFF because the framework overhead is minimal and you control the middleware stack precisely. Stack: Node.js 22 LTS + Koa + `@koa/router` + `pino` (structured logs) + Redis for caching/rate limits, fronted by nginx or an ALB. Run multiple Node instances behind the load balancer, vertical scaling stops paying off past one worker per CPU core.

Each instance uses `pm2` or `node --cluster` to spawn worker processes (one per core). Inside the stack: aggressive Redis caching of upstream API responses with stale-while-revalidate, HTTP/2 to upstream services via `undici` (Node's built-in pool is fast), per-route circuit breakers (`opossum`) so a slow upstream doesn't cascade, and a 30-second hard timeout on every handler. Observability is non-negotiable: OpenTelemetry traces across the BFF and upstream services, RED metrics (rate/errors/duration) per route, and structured logs with request IDs so you can trace a single user's journey across instances.

Indian companies like Paytm and Cred run BFFs in roughly this shape, the magic is in keeping the middleware stack short and the upstream calls bounded. A few BFF-specific patterns worth highlighting: (1) Response aggregation, a single BFF endpoint may call 4-6 upstream services in parallel and merge the response, so use `Promise.all` (or `Promise.allSettled` if partial failures are acceptable) generously. (2) Per-user caching keyed on `userId + endpoint` rather than global cache, you don't want one user's data leaking into another's response. (3) Schema validation on the OUTPUT of upstream calls, not just inputs to the BFF, upstreams can break their contracts, and you'd rather fail fast than ship malformed data to the frontend. (4) Graceful degradation, if a non-critical upstream is down, return the partial response with a degraded flag so the UI can render a 'service partially down' banner.

Key Points

  • Cluster mode + pm2, one worker per CPU core
  • Redis cache with stale-while-revalidate for upstream responses
  • Circuit breakers around every upstream call
  • OpenTelemetry traces + RED metrics + pino structured logs
  • Hard timeout on every handler, predictable latency over completeness
  • Response aggregation via Promise.all/allSettled
  • Per-user caching keys, never global
  • Graceful degradation when non-critical upstreams fail
💡 Pro Tip: Profile your BFF in production with `0x` or `clinic.js`, Node's V8 profiler is the best tool to find hot paths. Most teams discover that their bottleneck is JSON serialisation or a single expensive upstream call, not the framework itself.
Q27

How does the downstream/upstream middleware model affect error handling and observability?

AdvancedMiddleware

Answer

The onion model makes wrap-around behaviour trivial, anything you can express as 'before and after the rest of the stack' fits naturally. For observability, this is huge: a single timing middleware can capture total request duration, including upstream services, because it sees both ends. For error handling, the same property means a top-level try/catch sees every error from every layer below, no matter how deep, there is no equivalent of Express's `next(err)` plumbing because awaited promises propagate rejections automatically.

The cost: you cannot 'continue past' an error the way Express's error-handling middleware can. If a downstream middleware throws, all upstream code after `await next()` is skipped. The right pattern is to NOT do error handling in the middle of the chain, let the top-level handler shape the response, and use specific catches only for retry/fallback logic where you genuinely want to swallow an error.

This is also why Koa makes distributed tracing (OpenTelemetry, Jaeger) easier to wire than Express, your trace span lives across `await next()`, capturing the entire request lifecycle without manual hooks. Concretely, an OpenTelemetry middleware can start a span on entry, attach the active span to `ctx`, and call `span.end()` after `await next()` returns, the span automatically covers everything: the route handler, the DB calls, the upstream HTTP requests. With Express, you'd need to hook into `res.on('finish')` and pass the span through `req` or via the AsyncLocalStorage API, which is more brittle and easier to break in middleware that uses callbacks.

Key Points

  • Onion model = single point to wrap timing, tracing, error handling
  • Top-level try/catch catches every downstream error automatically
  • Don't handle errors in the middle of the chain, let them bubble
  • Makes OpenTelemetry / distributed tracing significantly cleaner than Express
💡 Pro Tip: When you're learning the model, instrument your middleware with `console.log` markers before and after `await next()`, you'll see the onion order visually in your terminal and the intuition will click in 10 minutes.
Q28

How do you write a custom Koa middleware library that supports both async and sync internals?

AdvancedMiddleware

Answer

All Koa middleware MUST return a promise (or be an async function). If you want your library to internally call user-supplied callbacks that may be sync or async, wrap them in `Promise.resolve(maybeSync())`, this normalises a sync return to a resolved promise without an extra microtask if it was already a promise. Expose your middleware as a factory function so users can configure it: `module.exports = function myMiddleware(opts) { return async (ctx, next) => { ... } }`.

If your middleware composes multiple sub-middleware internally, use `koa-compose` so the composed result is itself a valid Koa middleware. Always `await next()` unless you intentionally want to short-circuit, and document that behaviour clearly. For testing, accept the dependency you need as an option (e.g. `redis` client) instead of importing it directly, this lets consumers inject mocks.

The Koa ecosystem follows these conventions consistently, and adhering to them means your library composes cleanly with `koa-compose`, `koa-mount`, `koa-router`, and the rest of the stack. Naming the returned function (instead of leaving it anonymous) helps debugging, stack traces will show `requestIdMiddleware` instead of `<anonymous>` which makes performance profiling and exception triage much easier. Publish your library with TypeScript types out of the box if you want adoption in 2026, the Koa community has largely moved to TypeScript and a JS-only library puts an unnecessary speed bump in front of consumers.

// koa-request-id, adds a unique ID to every request
const { randomUUID } = require('crypto');

module.exports = function requestId(opts = {}) {
  const header = opts.header || 'X-Request-ID';
  const generator = opts.generator || randomUUID;
  return async function requestIdMiddleware(ctx, next) {
    const id = ctx.get(header) || generator();
    ctx.state.requestId = id;
    ctx.set(header, id);
    await next();
  };
};
💡 Pro Tip: Adopt semantic versioning religiously, a breaking change in your middleware's API or behaviour should be a major version bump. Koa users build production stacks on middleware, and a surprise breaking change in a minor version will burn through the trust you've built.
Q29

How do you migrate an Express application to Koa without a full rewrite?

AdvancedMigration

Answer

Full rewrite is rarely the right play, most migrations happen incrementally. Strategy: (1) Run both frameworks side by side behind nginx, with new routes built in Koa and old routes left in Express. (2) Use `koa-connect` to mount Express middleware inside a Koa app, which lets you reuse battle-tested Express middleware (Helmet, Morgan, body-parser variants) while you migrate. (3) Convert one route group at a time, starting with the simplest (e.g. health checks, then read-only routes, then mutating routes). (4) Rebuild error handling first, Koa's centralised error handler is one of the biggest wins and pays off immediately. (5) Move shared concerns (auth, logging, rate limiting) into Koa middleware first so each migrated route gets the new infrastructure for free. Gotchas to watch: `req.body` vs `ctx.request.body`, callback-style error patterns that don't translate, and Express's looser middleware contracts (some middleware mutates `res` directly in ways Koa doesn't expect).

Plan 3-6 months for any non-trivial migration, and document the cutover criteria upfront so the migration doesn't drag indefinitely. Honest assessment: in 2026 you should also weigh whether Koa is the right target. If your team is small and you're tired of Express, Fastify or Hono might be a better destination, Koa won't give you a meaningful performance boost over Express and the ecosystem is smaller. Migrate to Koa if your team specifically values the onion-model middleware semantics or if you already have Koa expertise in-house; otherwise consider whether a one-step migration to a more opinionated modern framework might be a better use of the migration budget.

Key Points

  • Run side by side behind nginx, not a big-bang rewrite
  • koa-connect to mount Express middleware inside Koa
  • Migrate shared concerns (auth/logging) first
  • Centralised error handling is the first big win
  • Convert simplest routes first, work outward
💡 Pro Tip: Write a small adapter layer that converts your existing Express route handlers into Koa middleware. Most routes follow a predictable pattern (`req.params`, `req.body`, `res.json`) and can be mechanically translated. The adapter lets you migrate hundreds of routes in days, not months.
Q30

How do you deploy a Koa application to production (Docker, PM2, monitoring)?

AdvancedDeployment

Answer

Production-grade Koa deployment in 2026 typically looks like this: package the app as a Docker image (`node:22-alpine` base, multi-stage build to keep the final image under 200MB), inject configuration through environment variables (or a secrets manager like Infisical/Vault, never bake secrets into the image), and run the container under an orchestrator (Kubernetes, ECS, or simpler, a single VM with Docker Compose for small teams). Inside the container, run Node with `pm2-runtime` or with `node --cluster` for multi-core utilisation. Wire structured logging to stdout (`pino`) so the orchestrator captures it; ship to ELK, Loki, or your hosted logging provider.

Add a `/health` endpoint that checks DB and Redis connectivity for liveness probes, and a `/ready` endpoint that gates traffic only after caches are warm. Metrics: expose `/metrics` via `prom-client` so Prometheus scrapes it; pair with Grafana dashboards for RED metrics. Distributed tracing: `@opentelemetry/sdk-node` with auto-instrumentation captures Koa, http, pg, and redis spans without code changes, send to Tempo, Jaeger, or a hosted backend like SignOz (used by several Indian product companies).

Set Node.js `--max-old-space-size` to ~75% of the container memory limit so V8 doesn't OOM under load. Last but not least: run a load test (`autocannon`, `k6`) before launch to confirm your stack handles the expected RPS with headroom, don't let production traffic be the first real test. Graceful shutdown is the other production must-have: trap SIGTERM, stop accepting new connections, drain in-flight requests (with a hard cap, e.g. 30 seconds), then exit cleanly.

Without this, deploys will drop requests as the orchestrator kills pods mid-flight. The standard pattern is to capture the `server` reference from `app.listen()`, call `server.close()` on SIGTERM, and `await` outstanding work, there are also wrappers like `http-graceful-shutdown` and `terminus` that handle the edge cases (still-active websockets, slow streaming responses) for you.

Key Points

  • node:22-alpine multi-stage Docker image, under 200MB final
  • pm2-runtime or cluster mode inside the container
  • Secrets manager (Infisical/Vault), not env files
  • /health for liveness, /ready for traffic gating
  • OpenTelemetry auto-instrumentation for traces
  • Graceful SIGTERM handling, no dropped requests on deploys
  • Prometheus + Grafana + OpenTelemetry, non-negotiable in 2026
  • Load test with k6 or autocannon before launch
💡 Pro Tip: Set a sensible `keepAliveTimeout` (e.g. 65 seconds) on your Koa server when behind AWS ALB. ALB's idle timeout is 60s, if Node closes the connection first, ALB sees a 502. The fix is for Node's timeout to be longer than the proxy's.

Companies Hiring Koa

Alibaba
Yahoo
ShareLaTeX
Paytm
Razorpay
Zomato
Cred

Salary Insights

Average in India
₹6-20 LPA

Frequently Asked Questions

Is Koa still worth learning in 2026 given Fastify and Hono exist?

Yes if you'll be working on existing Koa codebases (which are common at companies like Alibaba/Egg.js, Paytm, Cred) or if you value framework minimalism. For greenfield projects in 2026, Fastify usually wins on raw performance and built-in features, and Hono wins for edge/serverless deployments. Koa sits in the middle: cleaner than Express, less opinionated than Fastify, and the onion-model middleware idea has shaped how Node frameworks think about request flow. Even if you don't write new Koa code, understanding the model is valuable because the same patterns show up in modern frameworks across multiple languages.

How much does a Koa developer earn in India in 2026?

₹6-20 LPA for backend developers with Koa in their stack, though most listings ask for Node.js generally and Koa is one of several frameworks you'd be expected to know. Companies hiring Node.js backend engineers in this range: Paytm, Cred, Razorpay, Zomato, Swiggy, Flipkart, and many fintech/D2C startups. Senior roles (₹25 LPA+) typically expect deep system design knowledge alongside the framework chops, knowing Koa specifically rarely commands a premium, but knowing it well as part of a broader Node.js skillset signals you can pick up frameworks fast and care about the underlying primitives.

When should I choose Koa over Express?

Choose Koa when you want a smaller, more modern framework core and value the onion-model middleware for things like timing, tracing, and centralised error handling. Choose Express when you need the largest possible ecosystem of pre-built middleware, when your team is more comfortable with callback-style code, or when you're hiring at scale (Express knowledge is more common). For most new projects in 2026, the more interesting question is Koa vs Fastify vs Hono, Express's dominance has eroded. If you want to stay on the well-trodden path, Express is still the easiest hire and has the most plugins; if you want a sharper, more modern feel without committing to a heavily opinionated framework, Koa is the natural choice.

Does Koa support TypeScript out of the box?

Koa ships with TypeScript definitions (via `@types/koa` and `@types/koa__router`) and works well with TypeScript projects. There's no separate TypeScript-first Koa variant the way Fastify has gone, but the explicit `ctx` object means TypeScript can give you good inference once you augment the Koa context interface with your custom state and properties. The common pattern is to declare a module augmentation like `declare module 'koa' { interface DefaultState { user?: User; requestId?: string } }`, after which every `ctx.state` access is typed automatically. For full request/response typing, `koa-zod-router` or hand-written typed handlers give you the static safety equivalent of Fastify's schema-driven typing.

What's the relationship between Koa and Egg.js?

Egg.js is Alibaba's enterprise framework built on top of Koa. It adds opinionated conventions (folder structure, config loading, plugin system, multi-process architecture) that Koa intentionally leaves out. If you're working with large Chinese tech companies, you'll see Egg.js often; in India and the West, plain Koa or Koa with a custom internal framework is more common. Egg.js is roughly to Koa what Nest.js is to Express, a higher-level framework that bakes in opinions about how a large team should structure code. Both let you escape the conventions when needed by dropping down to plain Koa/Express middleware.

How does Koa compare to Fastify for performance?

Fastify is faster than Koa in synthetic benchmarks (often 2-3x more requests/second on identical hardware) because of its highly optimised JSON serialisation via schemas and its lighter router. In real-world apps where the bottleneck is usually a database or upstream API, the difference shrinks dramatically. Pick Fastify if you're CPU-bound on serialisation; pick Koa if you value the middleware model or already have Koa expertise on the team. For sub-millisecond endpoints (e.g. a status check returning a static JSON object), Fastify wins clearly; for endpoints that fan out to three upstream services and a database, both frameworks are bottlenecked on I/O and the framework choice is a rounding error.

Introduction

Koa is a minimalist Node.js web framework built by the team behind Express (TJ Holowaychuk and collaborators) as a spiritual successor that drops the callback-and-next() model in favour of async functions. Where Express embraces a kitchen-sink approach, Koa ships a tiny core (~600 lines), pushes everything else into middleware, and gives every request a unified `ctx` object instead of separate `req`/`res` parameters. The framework was first released in 2013 alongside generator functions, then rewritten in 2017 for async/await, the version most teams run in production today is Koa 2.x, with Koa 3.x (released 2024) adding pure ESM and Node 18+ requirements.

In 2026, Koa remains a strong pick for teams that want fine-grained control over their middleware stack, Alibaba runs huge Koa fleets via the Egg.js superset, and several Indian product companies (Paytm, Cred, parts of Razorpay) use Koa for API gateways and BFF (Backend for Frontend) layers where predictable middleware behaviour matters more than batteries-included convenience. The trade-off Koa makes is clear: you get a smaller, sharper tool, but you assemble your own router, body parser, validation, and auth from independent packages. For teams that value minimalism and have the discipline to choose middleware deliberately, this is liberating; for teams that want everything decided upfront, Express, Fastify, or Nest.js make life easier.

This guide covers the 30 most-asked Koa interview questions in 2026, organised by difficulty (12 basic / 13 intermediate / 5 advanced). Each answer focuses on what Koa actually does differently from Express, the gotchas around the context object and the downstream/upstream middleware flow, and production patterns for routing, auth, error handling, testing, websockets, and deployment. Where helpful, the answers cite real-world setups used by Indian product companies and give salary context for the role.

Ready to practice Koa interviews?

Don't just read, practice these Koa questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview