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/jsonso the server knows the body is JSON, not a form or plain text. - On responses: the server sets
Content-Type: application/jsonso 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
Bufferchunks and emits one or moredataevents onreq— a large body may arrive as many chunks, a small one as a single chunk. - 3. Each
datahandler runs synchronously, appending tobodyand checking the size limit. - 4. Once the client has sent the entire body and the connection signals completion, the
endevent fires exactly once. - 5. Inside
end,JSON.parseruns 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.urlincludes the query string (/api/users?limit=10) — usenew URL(req.url, "http://localhost")to split path from query rather than string-matching the rawreq.url. - Mixing up
JSON.parse(string → JS value) andJSON.stringify(JS value → string) — a surprisingly common typo under deadline pressure.
Best Practices
- Always set
Content-Type: application/jsonon both requests you send and responses you return. - Wrap every
JSON.parsecall on external input in atry/catchand reply with400, 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/catchin 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
httpserver with aGET /api/users/:id-style route (you’ll need to parse the id out ofreq.urlyourself) that returns404with a JSON error body when the id isn’t found. - Add validation to the POST handler in Example 2 so that a
namelonger 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 with400and a descriptive error message. - Rewrite Example 3’s Express app to add a
DELETE /api/users/:idroute that removes a user and responds204with no body, then usefetchto 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/jsonheader. - The core
httpmodule does not parse bodies for you —reqis a readable stream, so you accumulatedataevents and parse onend. - Always guard
JSON.parsewith atry/catchand 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 byres.end(JSON.stringify(value)), and make sure every code path callsres.end(). - Express’s
express.json()andres.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 — checkresponse.okyourself.
