Working with JSON APIs

Almost every modern web API speaks JSON: a client sends a JSON-encoded request body, and the server replies with a JSON-encoded response body. Node.js has no built-in “JSON middleware” for its core http module — you read the raw request as a stream of bytes and parse it yourself, or you use a framework like Express that does it for you. Understanding both the manual and framework approaches is essential, because it explains exactly what’s happening when things go wrong.

Overview: How JSON Travels Over HTTP

JSON itself is just text. There is no special “JSON protocol” — a JSON API is really just an HTTP request/response where the body happens to contain a JSON string, and the Content-Type header tells the other side how to interpret it. Two headers matter most:

  • On requests: the client sets Content-Type: application/json so the server knows the body is JSON, not a form or plain text.
  • On responses: the server sets Content-Type: application/json so the client (browser, fetch, another service) parses the body correctly instead of treating it as plain text.

Under the hood, Node’s http.IncomingMessage (the req object in a request handler) is a readable stream. The request body does not arrive all at once — it arrives in chunks as Buffer objects, emitted through data events, as the operating system delivers packets over the socket. Node does not buffer the whole body into memory for you and does not parse it as JSON automatically. You must: listen for data events to accumulate the chunks, listen for the end event to know the body is complete, concatenate the chunks into a string, and only then call JSON.parse(). This is exactly what body-parsing middleware like express.json() does internally — it is not magic, just a stream reader with a size limit and a try/catch around JSON.parse.

Sending JSON back is simpler: build a JavaScript value, call JSON.stringify() on it, set the Content-Type header, and call res.end() with the resulting string. Because res is a writable stream, res.end(string) writes the final chunk and closes the response in one call.

Syntax

The general shape of a hand-rolled JSON endpoint using the core http module looks like this:

const server = http.createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/path") {
    const data = await readJsonBody(req);   // accumulate + JSON.parse
    res.writeHead(201, { "Content-Type": "application/json" });
    res.end(JSON.stringify(result));
  }
});
Piece Purpose
req.on("data", cb) Fires for each chunk of the incoming body (a Buffer)
req.on("end", cb) Fires once the whole body has arrived
JSON.parse(body) Converts the accumulated string into a JS value; throws on invalid JSON
res.writeHead(status, headers) Sets the status code and Content-Type before any body is written
JSON.stringify(value) Converts a JS value into a JSON string for the response body
res.end(body) Writes the final chunk and closes the response — every request path must call this exactly once

Examples

Example 1: A read-only JSON endpoint

The simplest case is a GET route that returns static or in-memory data as JSON. No body parsing is needed since GET requests don’t carry a body.

const http = require("node:http");

const users = [
  { id: 1, name: "Ada Lovelace" },
  { id: 2, name: "Grace Hopper" },
];

const server = http.createServer((req, res) => {
  if (req.method === "GET" && req.url === "/api/users") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(users));
    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output:

Server running at http://localhost:3000
# GET /api/users responds with:
[{"id":1,"name":"Ada Lovelace"},{"id":2,"name":"Grace Hopper"}]

Note that every response path — the matched route and the 404 fallback — sets Content-Type and calls res.end(). A request that falls through without either header or a final end() call will hang forever from the client’s perspective.

Example 2: Parsing a JSON request body manually

To accept a POST body you must read the stream yourself. This example also guards against oversized payloads and malformed JSON — both real failure modes on a public endpoint.

const http = require("node:http");

const MAX_BODY_SIZE = 1e6; // 1 MB

function readJsonBody(req) {
  return new Promise((resolve, reject) => {
    let body = "";
    let size = 0;

    req.on("data", (chunk) => {
      size += chunk.length;
      if (size > MAX_BODY_SIZE) {
        req.destroy();
        reject(new Error("Payload too large"));
        return;
      }
      body += chunk;
    });

    req.on("end", () => {
      if (body === "") {
        resolve({});
        return;
      }
      try {
        resolve(JSON.parse(body));
      } catch (err) {
        reject(new Error("Invalid JSON"));
      }
    });

    req.on("error", reject);
  });
}

const server = http.createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/api/users") {
    try {
      const data = await readJsonBody(req);
      if (!data.name) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "name is required" }));
        return;
      }
      const created = { id: Date.now(), name: data.name };
      res.writeHead(201, { "Content-Type": "application/json" });
      res.end(JSON.stringify(created));
    } catch (err) {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: err.message }));
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output:

# POST /api/users with body {"name":"Margaret Hamilton"}
# responds 201 with:
{"id":1753900000000,"name":"Margaret Hamilton"}

The readJsonBody helper returns a Promise so the handler can simply await it, keeping the async flow readable instead of nesting callbacks. The size check protects the server from a client streaming gigabytes of data into memory; the try/catch around JSON.parse turns a malformed body into a clean 400 response instead of an unhandled exception.

Example 3: The same API with Express

In real projects you’ll usually reach for Express, which provides body parsing, routing, and response helpers out of the box.

npm install express
const express = require("express");

const app = express();
app.use(express.json());

const users = [
  { id: 1, name: "Ada Lovelace" },
  { id: 2, name: "Grace Hopper" },
];

app.get("/api/users", (req, res) => {
  res.json(users);
});

app.post("/api/users", (req, res) => {
  const { name } = req.body;
  if (!name) {
    return res.status(400).json({ error: "name is required" });
  }
  const created = { id: Date.now(), name };
  users.push(created);
  res.status(201).json(created);
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(400).json({ error: "Invalid JSON" });
});

app.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

express.json() is middleware that does exactly what readJsonBody did by hand: reads the stream, enforces a size limit, and parses the body — then attaches the result to req.body. res.json(value) sets Content-Type: application/json and calls JSON.stringify for you. The four-argument function at the bottom is Express’s special error-handling middleware signature; when express.json() fails to parse a malformed body, it calls next(err) internally, and Express routes that error here instead of crashing the process.

Example 4: Calling a JSON API from Node with fetch

Node 18+ ships a global fetch, so no extra package is needed to call your own or a third-party JSON API.

async function createUser(name) {
  const response = await fetch("http://localhost:3000/api/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error);
  }

  return response.json();
}

const user = await createUser("Margaret Hamilton");
console.log(user);

Output:

{ id: 1753900000123, name: 'Margaret Hamilton' }

fetch does not throw on HTTP error statuses like 400 or 500 — it only rejects on network failure. That’s why the example checks response.ok explicitly before trusting the body.

How It Works Step by Step

For the manual POST example above, here is the exact order of events when a request arrives:

  • 1. The TCP connection delivers packets; libuv notifies the event loop’s poll phase that the socket is readable.
  • 2. Node wraps the incoming bytes in Buffer chunks and emits one or more data events on req — a large body may arrive as many chunks, a small one as a single chunk.
  • 3. Each data handler runs synchronously, appending to body and checking the size limit.
  • 4. Once the client has sent the entire body and the connection signals completion, the end event fires exactly once.
  • 5. Inside end, JSON.parse runs synchronously (it is CPU work, not I/O) and either resolves or rejects the Promise.
  • 6. The await readJsonBody(req) in the handler resumes once that Promise settles, and the response is written.

Nothing here touches libuv’s thread pool — reading a socket and parsing a string are handled directly by the event loop and the JS engine. The thread pool only comes into play for things like file system access or CPU-bound crypto, not ordinary JSON request handling.

Common Mistakes

Mistake 1: Sending a JS object instead of a JSON string

res.end() expects a string or Buffer, not a JavaScript object. Passing the object directly throws a TypeError, and forgetting the Content-Type header leaves the client guessing how to parse the body.

const server = http.createServer((req, res) => {
  const users = getUsers();
  res.writeHead(200);
  res.end(users); // TypeError: argument must be a string or Buffer
});

Fix it by stringifying the value and declaring the content type:

const server = http.createServer((req, res) => {
  const users = getUsers();
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify(users));
});

Mistake 2: Calling JSON.parse without a try/catch

Any client can send a malformed body — a typo, a truncated request, or a deliberately bad payload. An uncaught JSON.parse error inside an event handler is an unhandled exception, and in older callback-style code it can crash the whole process, taking down every other in-flight request too.

req.on("end", () => {
  const data = JSON.parse(body); // throws on invalid JSON, uncaught
  res.end(JSON.stringify({ received: data }));
});

Always wrap the parse in a try/catch and respond with 400:

req.on("end", () => {
  try {
    const data = JSON.parse(body);
    res.end(JSON.stringify({ received: data }));
  } catch (err) {
    res.writeHead(400, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: "Invalid JSON" }));
  }
});

Other frequent pitfalls

  • Not setting a body size limit, letting a client stream unbounded data into a string held entirely in memory.
  • Forgetting that req.url includes the query string (/api/users?limit=10) — use new URL(req.url, "http://localhost") to split path from query rather than string-matching the raw req.url.
  • Mixing up JSON.parse (string → JS value) and JSON.stringify (JS value → string) — a surprisingly common typo under deadline pressure.

Best Practices

  • Always set Content-Type: application/json on both requests you send and responses you return.
  • Wrap every JSON.parse call on external input in a try/catch and reply with 400, never let it crash the process.
  • Enforce a maximum body size before parsing — both hand-rolled readers and express.json({ limit: "1mb" }) support this.
  • Validate the parsed shape (required fields, types) before using it — a syntactically valid JSON body can still be semantically wrong.
  • Use the status code table below so clients can branch on outcome without parsing the body first.
  • End every response path exactly once; a handler that returns without calling res.end() leaves the client hanging.
  • In Express, centralize error handling in a single four-argument error middleware instead of repeating try/catch in every route.
Status Meaning When to use it
200 OK Success GET/PUT succeeded and the response has a body
201 Created Resource created POST that creates a new resource
204 No Content Success, no body DELETE or update with nothing to return
400 Bad Request Client error Malformed JSON or a missing required field
404 Not Found Client error Route or resource doesn’t exist
500 Internal Server Error Server error Unexpected exception while handling the request

Practice Exercises

  • Extend Example 2’s core http server with a GET /api/users/:id-style route (you’ll need to parse the id out of req.url yourself) that returns 404 with a JSON error body when the id isn’t found.
  • Add validation to the POST handler in Example 2 so that a name longer than 100 characters, or a request body that isn’t a JSON object at all (e.g. a bare JSON array or number), is rejected with 400 and a descriptive error message.
  • Rewrite Example 3’s Express app to add a DELETE /api/users/:id route that removes a user and responds 204 with no body, then use fetch to call it and confirm the response has an empty body.

Summary

  • JSON APIs are ordinary HTTP requests/responses whose body is a JSON string, identified by the Content-Type: application/json header.
  • The core http module does not parse bodies for you — req is a readable stream, so you accumulate data events and parse on end.
  • Always guard JSON.parse with a try/catch and enforce a body size limit; both protect the server from bad or malicious input.
  • Send responses with res.writeHead(status, { "Content-Type": "application/json" }) followed by res.end(JSON.stringify(value)), and make sure every code path calls res.end().
  • Express’s express.json() and res.json() automate the same pattern you can (and should understand how to) write by hand.
  • Node’s global fetch (18+) lets you consume JSON APIs without any extra dependency, but remember it only rejects on network errors — check response.ok yourself.