5 ways to create an HTTP or HTTP/2 server in Node.js: node:http, Express 5, Fastify 5, Hono and node:http2
There are plenty of ways to start an HTTP server in Node.js. This post builds the same small API five ways, so you can compare them directly:
GET /returns plain text.GET /users/42returns JSON.GET /users/abcshould not return a user.- Anything else returns a JSON 404.
Every server was run on stock node:24-slim and node:26-slim images and hit with curl, and the HTTP/2 server was also loaded in headless Chromium 153. The versions used were [email protected], [email protected], [email protected] and @hono/[email protected]. The third route turned out to be the most interesting test.
1. node:http, no dependencies
import { createServer } from 'node:http';
const server = createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
const user = pathname.match(/^\/users\/(\d+)$/);
if (req.method === 'GET' && pathname === '/') {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('Hello from node:http\n');
} else if (req.method === 'GET' && user) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ id: Number(user[1]), name: `User ${user[1]}` }));
} else {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
}
});
server.listen(3000, () => console.log('node:http on http://localhost:3000'));$ curl -i localhost:3000/users/42
HTTP/1.1 200 OK
content-type: application/json
Date: Thu, 01 Oct 2026 07:30:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"id":42,"name":"User 42"}No install and nothing to update, and it's the base the frameworks below are built on. The price is that you parse URLs, match routes and set headers yourself. That's fine for a health-check endpoint, a webhook receiver or a small internal tool. Beyond a few routes, use a framework.
For development, node --watch http.mjs restarts the server when a file changes. Watch mode is stable since Node.js 22.0 and 20.13, so you don't need nodemon.
2. Express 5
npm install expressimport express from 'express';
const app = express();
app.get('/', (req, res) => res.type('text').send('Hello from Express\n'));
app.get('/users/:id', (req, res, next) => {
const id = Number(req.params.id);
// Express 5 has no regex constraints in paths, so validate here.
// next() falls through to the 404 handler below.
if (!Number.isInteger(id)) return next();
res.json({ id, name: `User ${id}` });
});
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.listen(3000, () => console.log('Express on http://localhost:3000'));Express 5 is the current major version. Rejected promises in async handlers now reach the error handler on their own: an async route that throws new Error('db is down') returned 500 {"error":"db is down"} from a plain error middleware, so you no longer need express-async-handler-style wrappers. Note the explicit Number.isInteger check. The first version of this example passed req.params.id straight to Number(), and /users/abc returned this:
/users/abc 200 application/json; charset=utf-8 | {"id":null,"name":"User abc"}That's a 200 for a user that doesn't exist, with NaN serialised as null. Route parameters are always strings, and nothing validates them for you.
Migration trap: the Express 4 way to constrain a parameter, '/users/:id(\\d+)', is no longer supported. Express 5 uses a newer path-to-regexp without inline regular expressions, and it throws when you register the route:
TypeError: Unexpected ( at index 10: /users/:id(\d+); visit https://git.new/pathToRegexpError for infoValidate in the handler (or with a validation middleware) instead, as above.
3. Fastify 5
npm install fastifyimport Fastify from 'fastify';
const app = Fastify();
app.get('/', async (request, reply) => {
reply.type('text/plain');
return 'Hello from Fastify\n';
});
// The schema validates and coerces :id to a number, and rejects /users/abc with a 400.
app.get('/users/:id', {
schema: { params: { type: 'object', properties: { id: { type: 'integer' } } } },
}, async (request) => ({ id: request.params.id, name: `User ${request.params.id}` }));
const address = await app.listen({ port: 3000, host: '0.0.0.0' });
console.log(`Fastify on ${address}`);Fastify is built around JSON schemas. The params schema above both converts :id to a number and rejects anything that isn't an integer, so it was the only framework that handled /users/abc correctly without extra code:
/users/42 200 application/json; charset=utf-8 | {"id":42,"name":"User 42"}
/users/abc 400 application/json; charset=utf-8 | {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"params/id must be integer"}
/nope 404 application/json; charset=utf-8 | {"message":"Route GET:/nope not found","error":"Not Found","statusCode":404}Two Fastify habits to know: listen() binds to localhost only unless you pass host: '0.0.0.0', which is what you need inside Docker. And handlers can simply return the response body.
4. Hono
npm install hono @hono/node-serverimport { serve } from '@hono/node-server';
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Hono\n'));
// {[0-9]+} constrains the parameter, so /users/abc never reaches this handler.
app.get('/users/:id{[0-9]+}', (c) => {
const id = c.req.param('id');
return c.json({ id: Number(id), name: `User ${id}` });
});
app.notFound((c) => c.json({ error: 'Not found' }, 404));
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Hono on http://localhost:${info.port}`);
});Hono is written against the web-standard Request/Response APIs, so the same app runs on Node.js, Deno, Bun, Cloudflare Workers and other edge runtimes. On Node.js, @hono/node-server is the adapter. Unlike Express 5, Hono does support regex constraints in the path: with :id{[0-9]+}, /users/abc never matches the route and falls through to the 404 handler.
/ 200 text/plain; charset=UTF-8 | Hello from Hono
/users/42 200 application/json | {"id":42,"name":"User 42"}
/users/abc 404 application/json | {"error":"Not found"}
/nope 404 application/json | {"error":"Not found"}Hono replaces Hapi from the first version of this post. If you do use Hapi, the old hapi package on npm is deprecated (its last release was in 2019). The framework lives on as @hapi/hapi.
5. node:http2, the version browsers can actually use
This is the snippet most tutorials show, including the original version of this post:
import { createServer } from 'node:http2';
const server = createServer({ key, cert }, handler); // ← not what you wanthttp2.createServer() creates a cleartext HTTP/2 server ("h2c"). It accepts an options object, so passing key and cert doesn't throw. They're simply ignored. No browser speaks HTTP/2 without TLS, so nothing normal can connect to it:
old example, curl https curl: (35) …:SSL routines::wrong version number
old example, curl http (HTTP/1.1) curl: (1) Received HTTP/0.9 when not allowed
old example, curl --http2-prior-knowledge Hello over HTTP/2.0
Chromium: http://localhost:3003/ net::ERR_INVALID_HTTP_RESPONSEOnly a client that already knows it should speak cleartext HTTP/2 gets through. For a server a browser can reach, use createSecureServer():
import { readFileSync } from 'node:fs';
import { createSecureServer } from 'node:http2';
const server = createSecureServer({
key: readFileSync('localhost-key.pem'),
cert: readFileSync('localhost-cert.pem'),
// Also accept HTTP/1.1 clients on the same port (negotiated via ALPN).
allowHTTP1: true,
}, (req, res) => {
// The compatibility API: the same req/res shape as node:http.
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`Hello over HTTP/${req.httpVersion}\n`);
});
server.listen(3001, () => console.log('HTTP/2 on https://localhost:3001'));For local development, a self-signed certificate is enough:
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 \
-subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost' \
-keyout localhost-key.pem -out localhost-cert.pemsecure, allowHTTP1, curl --http2 Hello over HTTP/2.0 [proto 2]
secure, allowHTTP1, curl --http1.1 Hello over HTTP/1.1 [proto 1.1]
secure, no allowHTTP1, curl --http1.1 curl: (35) …:tlsv1 alert no application protocol
Chromium: https://localhost:3001/ 200 Hello over HTTP/2.0 (nextHopProtocol: h2)Two things to notice:
- HTTP/2 isn't automatically backwards-compatible. Without
allowHTTP1: true, an HTTP/1.1 client is refused during the TLS handshake. With it, client and server negotiate the protocol per connection (ALPN), and the same handler serves both. - The handler uses the compatibility API, the same
req/resshape asnode:http. That's what makesallowHTTP1work. The lower-levelserver.on('stream', …)API is HTTP/2-only.
In production you usually don't need any of this in Node itself. A reverse proxy or CDN (nginx, Caddy, Cloudflare, a cloud load balancer) terminates TLS and speaks HTTP/2 or HTTP/3 to browsers, and talks plain HTTP/1.1 to your app. Run HTTP/2 in Node when the app terminates TLS itself, or for gRPC.
Which one?
node:httpfor a handful of routes and zero dependencies.- Express 5 when you want the biggest ecosystem of middleware and examples, and you're fine adding validation yourself.
- Fastify 5 for JSON APIs. Schema validation and serialization are built in, and it caught the bad input without extra code.
- Hono if the same code may need to run on edge runtimes or Bun as well as Node.js.
node:http2withcreateSecureServeronly when Node terminates TLS itself. Otherwise let the proxy handle HTTP/2.