React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nodejshttpexpressfastifyapitypescript

Making HTTP Servers

Three ways to build HTTP servers in Node.js — node:http for zero dependencies, Express for familiarity, Fastify for performance.

Recipe

Quick-reference recipe card — copy-paste ready.

// 1. node:http — zero dependencies
import { createServer } from "node:http";
 
createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ ok: true }));
}).listen(3000);
 
// 2. Express — the familiar choice
import express from "express";
 
const app = express();
app.use(express.json());
app.get("/", (req, res) => res.json({ ok: true }));
app.listen(3000);
 
// 3. Fastify — high performance
import Fastify from "fastify";
 
const fastify = Fastify({ logger: true });
fastify.get("/", async () => ({ ok: true }));
await fastify.listen({ port: 3000 });

When to reach for this: Any time you need a long-running HTTP service — internal APIs, webhooks, SSR fallbacks, background workers with a health endpoint.

Working Example

A tiny REST API with three routes — GET /, GET /users/:id, POST /users — side by side in all three frameworks.

// --- node:http ---
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
 
type User = { id: string; name: string };
const users = new Map<string, User>();
 
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
  res.setHeader("content-type", "application/json");
 
  if (req.method === "GET" && req.url === "/") {
    res.end(JSON.stringify({ ok: true }));
    return;
  }
 
  const idMatch = req.url?.match(/^\/users\/([^/]+)$/);
  if (req.method === "GET" && idMatch) {
    const user = users.get(idMatch[1]);
    if (!user) {
      res.statusCode = 404;
      res.end(JSON.stringify({ error: "not found" }));
      return;
    }
    res.end(JSON.stringify(user));
    return;
  }
 
  if (req.method === "POST" && req.url === "/users") {
    let body = "";
    for await (const chunk of req) body += chunk;
    const parsed = JSON.parse(body) as User;
    users.set(parsed.id, parsed);
    res.statusCode = 201;
    res.end(JSON.stringify(parsed));
    return;
  }
 
  res.statusCode = 404;
  res.end(JSON.stringify({ error: "not found" }));
});
 
server.listen(3000, () => console.log("node:http on :3000"));
// --- Express ---
import express, { type Request, type Response } from "express";
 
type User = { id: string; name: string };
const users = new Map<string, User>();
 
const app = express();
app.use(express.json());
 
app.get("/", (_req: Request, res: Response) => {
  res.json({ ok: true });
});
 
app.get("/users/:id", (req: Request<{ id: string }>, res: Response) => {
  const user = users.get(req.params.id);
  if (!user) return res.status(404).json({ error: "not found" });
  res.json(user);
});
 
app.post("/users", (req: Request<unknown, unknown, User>, res: Response) => {
  users.set(req.body.id, req.body);
  res.status(201).json(req.body);
});
 
app.listen(3000, () => console.log("express on :3000"));
// --- Fastify ---
import Fastify from "fastify";
 
type User = { id: string; name: string };
const users = new Map<string, User>();
 
const fastify = Fastify({ logger: true });
 
fastify.get("/", async () => ({ ok: true }));
 
fastify.get<{ Params: { id: string } }>("/users/:id", async (req, reply) => {
  const user = users.get(req.params.id);
  if (!user) return reply.code(404).send({ error: "not found" });
  return user;
});
 
fastify.post<{ Body: User }>(
  "/users",
  {
    schema: {
      body: {
        type: "object",
        required: ["id", "name"],
        properties: {
          id: { type: "string" },
          name: { type: "string" },
        },
      },
    },
  },
  async (req, reply) => {
    users.set(req.body.id, req.body);
    return reply.code(201).send(req.body);
  }
);
 
await fastify.listen({ port: 3000 });

What this demonstrates:

  • node:http forces you to handle routing, body parsing, and status codes yourself — minimal but verbose.
  • Express middleware (express.json()) gives you req.body for free and uses familiar req/res semantics.
  • Fastify adds schema-based validation and serialization, plus a built-in logger, with async handlers by default.

Deep Dive

How It Works

All three frameworks ultimately wrap node:http. createServer returns an http.Server — itself an EventEmitter — that emits 'request' for each incoming message. Express and Fastify install a single 'request' listener that runs their routing and middleware pipelines.

Express uses a linear middleware chain: each function calls next() to pass control. Fastify uses a schema-driven hook system with precompiled JSON serializers, which is where most of its speed comes from.

Variations

node:http with JSON parsing. There is no built-in body parser — read the request as an async iterable of buffers and parse manually (see the working example). For large bodies, guard against unbounded memory with a byte limit.

Express middleware chain. Order matters. Put express.json() before routes that need req.body, put error handlers (functions with four parameters) last, and mount CORS/helmet before any business logic.

Fastify schemas. Declaring a JSON schema for body, params, querystring, and response validates input automatically and compiles a fast serializer for the response. You get 400 errors for free on bad input.

Async route handlers. Express 4 silently swallows thrown errors in async handlers unless you wrap them or use express-async-errors. Express 5 forwards rejected promises to the error middleware. Fastify awaits async handlers natively.

Graceful shutdown. Listen for SIGTERM, stop accepting new connections with server.close(), and drain in-flight requests before exiting:

process.on("SIGTERM", () => {
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 10_000).unref();
});

Health check endpoint. Add GET /healthz returning 200 for liveness. Keep it dependency-free so a broken DB does not fail the liveness probe — use /readyz for dependency checks.

CORS and error middleware. For Express, app.use(cors()) early, and a final (err, req, res, next) => ... error handler last. For Fastify, register @fastify/cors and use setErrorHandler.

TypeScript Notes

For node:http, import IncomingMessage and ServerResponse from node:http and annotate the handler signature. For Express, install @types/express and use Request and ResponseRequest takes generics for params, response body, request body, and query: Request<Params, ResBody, ReqBody, Query>. For Fastify, pass a generics object directly to the route: fastify.get<\{ Params: \{ id: string \} \}>("/users/:id", handler) (escape braces when writing this in markdown). Fastify's generics also support Body, Querystring, Headers, and Reply.

Gotchas

  1. Forgetting res.end() in node:http. The request hangs until the client times out. Every code path must call res.end() (or res.writeHead().end()), including error branches.
  2. Unhandled errors in async Express 4 handlers. A thrown error or rejected promise is silently swallowed and the request hangs. Wrap handlers or upgrade to Express 5.
  3. Middleware order. express.json() after a route means req.body is undefined. CORS after routes means preflight requests 404. Error handlers before routes never fire.
  4. CORS preflight. Browsers send OPTIONS before non-simple requests. If your router only registers GET/POST, preflight fails. Use a CORS middleware that handles OPTIONS globally.
  5. EADDRINUSE on restart. The previous process still holds the port. Either kill it, wait for the TIME_WAIT window, or set server.listen(\{ port, exclusive: false \}) — escaped braces in prose.
  6. Manual JSON parsing in node:http. Reading the whole body into a string lets a malicious client OOM your process. Enforce a max size and reject oversized requests with 413.
  7. Mixing return and res.send in Express. Returning a value does nothing; Express only responds when you call res.send/res.json. In Fastify, the opposite — returning the value sends it.

Alternatives

OptionBest ForNotes
node:httpMinimal services, learningZero deps, maximum control, maximum boilerplate.
ExpressLegacy apps, huge ecosystemFamiliar, slow relative to Fastify, middleware-first.
FastifyHigh-throughput JSON APIsSchema validation, fast serializer, great TS support.
HonoEdge runtimes (Workers, Deno, Bun)Tiny, fetch-based, runs on Node too.
KoaExpress creators' sequelAsync middleware, smaller core, smaller ecosystem.
h3Nitro / Nuxt server engineComposable, works on Node and edge.
Next.js API routesApps already on Next.jsCo-located with the frontend, less flexible for non-HTTP work.
Bun.serveBun runtimeFetch-based, extremely fast, Bun-only.

FAQs

Which of the three should I pick for a new API?

Fastify is the safe default for JSON APIs — fast, schema-validated, great TypeScript support. Pick Express only if your team already knows it or you need a specific middleware. Pick node:http only for tiny internal tools where a dependency is a dealbreaker.

How do I parse a JSON body without a framework?

Iterate the request as an async iterable of buffers, concatenate to a string, then JSON.parse. Always enforce a max size — track bytes read and reject with 413 if it exceeds your limit, or a malicious client can OOM the process.

What is the relationship between http.Server and EventEmitter?

http.Server extends EventEmitter and emits 'request', 'connection', 'close', and 'error'. Express and Fastify install a single 'request' listener that runs their own pipelines.

How do I implement graceful shutdown?

Listen for SIGTERM, call server.close() to stop accepting new connections, let in-flight requests finish, then exit. Add a timeout (e.g. 10s) that force-exits if anything hangs, and call .unref() on the timer so it does not keep the loop alive on its own.

Why do I get EADDRINUSE when I restart my dev server? (Gotcha)

The previous process still holds the port — either it did not shut down cleanly, or the OS is still in the TIME_WAIT window. Kill the stray process (lsof -i :3000) or change the port. A graceful-shutdown handler prevents this on intentional restarts.

Why does my async Express handler hang instead of returning a 500? (Gotcha)

In Express 4, thrown errors and rejected promises from async handlers are silently swallowed. Wrap handlers in a try/catch, install express-async-errors, or upgrade to Express 5 which forwards rejections to the error middleware automatically.

How do I type Express route params? (TypeScript)

Request takes four generics: Request<Params, ResBody, ReqBody, Query>. For GET /users/:id, use Request<\{ id: string \}>. For a POST with a typed body, use Request<unknown, unknown, MyBody>. Install @types/express first.

How do I type Fastify route params and body? (TypeScript)

Pass a generics object to the route method: fastify.get<\{ Params: \{ id: string \}; Querystring: \{ q: string \} \}>(...). Supported keys are Params, Querystring, Body, Headers, and Reply. Fastify infers req.params, req.query, and req.body from these types.

Do I need a CORS library, or can I set headers manually?

You can set Access-Control-Allow-Origin and friends by hand, but you must also handle OPTIONS preflight requests. A library (cors for Express, @fastify/cors for Fastify) handles both in one line and avoids subtle bugs.

Why does my middleware run after the route handler?

Express runs middleware in registration order. Middleware registered after app.get(...) only runs for routes registered afterwards. Always mount parsers, CORS, and loggers before your routes.

What is the difference between Express and Fastify response handling?

Express only responds when you call res.send / res.json — returning a value does nothing. Fastify is the opposite: return the value and it is sent automatically. Mixing the two mental models is a common source of hung requests.

Should I use Next.js API routes instead?

If your app is already on Next.js and the endpoint is tightly coupled to the frontend, yes — colocation is valuable. For standalone services, long-running jobs, or anything needing custom server behavior (WebSockets, streaming, graceful shutdown), a dedicated Fastify or Express server is a better fit.