express, hapi, koa, and micro are all popular Node.js frameworks used to build web servers and APIs, but they differ significantly in architecture, philosophy, and feature sets. express is the most widely adopted, offering a minimalist, unopinionated core with a massive ecosystem of middleware. hapi provides a rich, configuration-driven approach with built-in features for validation, caching, and security, favoring convention over code. koa, created by the Express team, modernizes the stack by leveraging async/await and removing callback-based middleware, offering a more elegant flow control. micro takes an extreme minimalist approach, focusing solely on building small, composable microservices with a functional programming style, relying heavily on native Node.js features.
When building backend services in Node.js, the choice of framework dictates your application's structure, error handling strategy, and long-term maintainability. While express, hapi, koa, and micro all serve HTTP requests, their underlying architectures solve common problems in fundamentally different ways. Let's examine how they handle routing, middleware flow, error management, and extensibility.
How you define URL paths and handle incoming requests varies from implicit conventions to explicit configuration.
express uses imperative method chaining to define routes directly on the app instance. It is straightforward but can become unwieldy in large files without manual organization.
// express: Imperative route definition
const express = require('express');
const app = express();
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id });
});
app.listen(3000);
hapi relies on a configuration object approach. Routes are defined in an array with explicit properties for method, path, and handler, promoting a clear separation of concerns.
// hapi: Configuration-based routes
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({ port: 3000 });
server.route({
method: 'GET',
path: '/users/{id}',
handler: (request, h) => {
return { id: request.params.id };
}
});
await server.start();
};
init();
koa does not include a router in its core. You must install a separate router package (like @koa/router), keeping the core library small but requiring extra setup.
// koa: External router required
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
router.get('/users/:id', (ctx) => {
ctx.body = { id: ctx.params.id };
});
app.use(router.routes());
app.listen(3000);
micro has no built-in routing system. It expects you to parse the URL manually or use a helper like micro-fork or path-to-regexp to dispatch logic based on the request URL.
// micro: Manual URL parsing
const { parse } = require('url');
const micro = require('micro');
const server = micro(async (req, res) => {
const { pathname } = parse(req.url);
if (pathname.startsWith('/users/')) {
const id = pathname.split('/')[2];
return { id };
}
return { error: 'Not found' };
});
server.listen(3000);
The mechanism for executing logic before or after a request reaches the handler is where these frameworks diverge most sharply.
express uses a linear middleware stack based on callbacks. You must explicitly call next() to pass control to the next function; forgetting to do so hangs the request.
// express: Callback-based middleware with next()
app.use((req, res, next) => {
console.log('Time:', Date.now());
next(); // Must call next() to continue
});
app.get('/', (req, res) => {
res.send('Hello');
});
hapi uses an extension point system (onRequest, pre, onResponse) rather than a free-form middleware stack. This enforces a structured lifecycle but offers less ad-hoc flexibility.
// hapi: Extension points in lifecycle
server.ext('onRequest', (request, h) => {
request.log('info', 'Request received');
return h.continue; // Explicitly continue
});
server.route({
method: 'GET',
path: '/',
handler: (request, h) => 'Hello'
});
koa introduces a "downstream-upstream" flow using async/await. Middleware functions wrap around the next function, allowing logic to run both before and after the downstream middleware completes naturally.
// koa: Async/await composition
app.use(async (ctx, next) => {
console.log('Start:', Date.now());
await next(); // Waits for downstream to finish
console.log('End:', Date.now());
});
app.use(async (ctx) => {
ctx.body = 'Hello';
});
micro does not have a middleware stack. Instead, it encourages composing small, single-purpose functions. You wrap your main handler with higher-order functions to achieve similar effects.
// micro: Function composition
const withLogging = (handler) => async (req, res) => {
console.log('Start:', Date.now());
const result = await handler(req, res);
console.log('End:', Date.now());
return result;
};
const handler = withLogging(async (req, res) => {
return { hello: 'world' };
});
How errors are caught and returned to the client differs from automatic catches to manual try/catch blocks.
express requires a special error-handling middleware with four arguments (err, req, res, next). If you forget this signature, errors will not be caught.
// express: Special error middleware signature
app.get('/fail', (req, res, next) => {
next(new Error('Something broke'));
});
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
hapi handles errors by returning an error object or throwing within the handler. The framework automatically catches these and formats the response based on configuration.
// hapi: Throw or return error objects
server.route({
method: 'GET',
path: '/fail',
handler: (request, h) => {
throw new Error('Something broke'); // Caught automatically
}
});
koa leverages native try/catch blocks due to its async/await foundation. Any error thrown in downstream middleware bubbles up and can be caught in a central middleware.
// koa: Native try/catch in context
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = 500;
ctx.body = { error: err.message };
}
});
app.use(async (ctx) => {
throw new Error('Something broke');
});
micro treats errors as rejected Promises. If your async handler throws or returns a rejected promise, micro automatically sends a 500 response with the error message.
// micro: Rejected promises handled automatically
const server = micro(async (req, res) => {
throw new Error('Something broke'); // Returns 500 automatically
});
The philosophy of "batteries included" versus "minimal core" determines how much you need to install separately.
express provides almost nothing out of the box besides routing. You must install separate packages for body parsing, validation, and security headers.
// express: Requires external middleware
const bodyParser = require('body-parser');
app.use(bodyParser.json()); // Not built-in
hapi includes input validation (via joi), caching, authentication schemes, and CORS handling as core features. This reduces dependency count but increases initial learning curve.
// hapi: Built-in validation
server.route({
method: 'POST',
path: '/user',
options: {
validate: {
payload: {
name: Joi.string().required()
}
}
},
handler: (request) => request.payload
});
koa strips away everything, including body parsing. You must explicitly add middleware for common tasks, giving you full control over what runs.
// koa: Body parser is external
const bodyParser = require('koa-bodyparser');
app.use(bodyParser()); // Must be added manually
micro assumes you are building a single endpoint. It automatically parses JSON bodies if the content-type matches, but offers no other utilities.
// micro: Auto JSON parsing for simple cases
const { send } = require('micro');
const micro = require('micro');
module.exports = micro(async (req, res) => {
// req is already parsed if Content-Type is application/json
const data = await req;
send(res, 200, data);
});
You need robust input validation, authentication strategies, and detailed logging out of the box.
hapiYou need to get a server running quickly with access to thousands of existing plugins and tutorials.
expressYou want clean, readable code for complex asynchronous flows without callback hell.
koaYou are deploying to AWS Lambda or Vercel and need the smallest possible footprint for a single task.
micro| Feature | express | hapi | koa | micro |
|---|---|---|---|---|
| Philosophy | Minimalist, unopinionated | Configuration-driven, robust | Modern, elegant core | Functional, single-purpose |
| Middleware | Callback-based (next()) | Lifecycle extensions | Async/Await composition | Function wrapping |
| Error Handling | Special 4-arg middleware | Automatic catch & format | Native try/catch | Rejected promise handling |
| Routing | Built-in | Built-in (config) | External package | Manual / External |
| Body Parsing | External (body-parser) | Built-in | External | Auto (JSON only) |
| Best For | General purpose, legacy | Enterprise, high security | Modern apps, complex flows | Microservices, serverless |
Your choice should align with your team's experience and the project's complexity.
If you need stability and ecosystem breadth, express remains the safe default, though it requires discipline to avoid spaghetti code.
If you are building large-scale systems where configuration and security are paramount, hapi pays off in reduced technical debt.
If you value code clarity and modern JavaScript features, koa offers the best developer experience for custom architectures.
If you are building tiny, isolated services, micro removes all distractions, letting you ship faster with less code.
Avoid micro for anything beyond simple endpoints, and be wary of hapi if your team prefers code-over-configuration. For most new greenfield projects today, koa or a modern express setup with strict linting offers the best balance of flexibility and maintainability.
Choose express if you need a battle-tested, flexible framework with the largest ecosystem of plugins and community support. It is ideal for teams that want to assemble their own architecture from proven components or need to integrate with legacy systems. However, be prepared to manually configure security, validation, and error handling, as the core is intentionally bare-bones.
Choose hapi if you prefer a framework that enforces structure and provides robust built-in capabilities for input validation, authentication, and caching. It is well-suited for large enterprise applications where configuration consistency and security defaults are more valuable than raw flexibility. Avoid it if you dislike heavy configuration objects or need a ultra-lightweight footprint.
Choose koa if you want a modern, lightweight foundation that fully embraces async/await for cleaner middleware logic and better error handling. It is perfect for developers who find Express's callback-style middleware cumbersome but still want the freedom to pick their own routing and validation libraries. Note that it does not include a router or body parser out of the box.
Choose micro only for building simple, stateless microservices or serverless functions where minimal boilerplate is critical. It forces a functional approach where every request is handled by a single async function. Do not use it for complex monolithic applications, as it lacks built-in routing, middleware stacks, and advanced request lifecycle management.
Fast, unopinionated, minimalist web framework for Node.js.
This project has a Code of Conduct.
import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello World')
})
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000')
})
This is a Node.js module available through the npm registry.
Before installing, download and install Node.js. Node.js 18 or higher is required.
If this is a brand new project, make sure to create a package.json first with
the npm init command.
Installation is done using the
npm install command:
npm install express
Follow our installing guide for more information.
PROTIP Be sure to read the migration guide to v5
The quickest way to get started with express is to utilize the executable express(1) to generate an application as shown below:
Install the executable. The executable's major version will match Express's:
npm install -g express-generator@4
Create the app:
express /tmp/foo && cd /tmp/foo
Install dependencies:
npm install
Start the server:
npm start
View the website at: http://localhost:3000
The Express philosophy is to provide small, robust tooling for HTTP servers, making it a great solution for single page applications, websites, hybrids, or public HTTP APIs.
Express does not force you to use any specific ORM or template engine. With support for over 14 template engines via @ladjs/consolidate, you can quickly craft your perfect framework.
To view the examples, clone the Express repository:
git clone https://github.com/expressjs/express.git --depth 1 && cd express
Then install the dependencies:
npm install
Then run whichever example you want:
node examples/content-negotiation
The Express.js project welcomes all constructive contributions. Contributions take many forms, from code for bug fixes and enhancements, to additions and fixes to documentation, additional tests, triaging incoming pull requests and issues, and more!
See the Contributing Guide for more technical details on contributing.
If you discover a security vulnerability in Express, please see Security Policies and Procedures.
To run the test suite, first install the dependencies:
npm install
Then run npm test:
npm test
For information about the governance of the express.js project, see GOVERNANCE.md.
The original author of Express is TJ Holowaychuk