Who this is for: Beginners who have run a Node.js HTTP server and want to accept a small JSON request body safely.
What you will learn: Read a request as a stream, cap its byte size, require JSON content, reject invalid UTF-8 and JSON, validate a field, and return meaningful HTTP status codes.
Prerequisites: Basic JavaScript and HTTP knowledge. The companion Node.js HTTP server lesson introduces node:http, routes, and response headers.
Tested environment: Node.js v24.22.1 using only built-in modules and an .mjs file. No package installation is required.
Why request bodies need deliberate handling
Unlike a query string, an HTTP request body arrives as a stream of bytes. It may arrive in several chunks, may be larger than expected, and may not contain the format the client claims to send. Do not assume one data event contains the whole body or parse arbitrary input without a size limit.
This example accepts a small JSON object with one name string. It returns a greeting but does not save data.
1. Create the complete server
Save the following as server.mjs. The body reader keeps at most 16 KiB in memory. It continues draining an oversized request without retaining additional chunks, then reports 413 Payload Too Large.
import { createServer } from "node:http";
const hostname = "127.0.0.1";
const port = 3000;
const MAX_BODY_BYTES = 16 * 1024;
const MAX_NAME_CODE_POINTS = 80;
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
function sendJson(response, statusCode, data, extraHeaders = {}) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
...extraHeaders,
});
response.end(JSON.stringify(data));
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes = 0;
let tooLarge = false;
let settled = false;
function fail(error) {
if (!settled) {
settled = true;
reject(error);
}
}
request.on("data", chunk => {
totalBytes += chunk.length;
if (totalBytes > MAX_BODY_BYTES) {
tooLarge = true;
chunks.length = 0;
return;
}
if (!tooLarge) {
chunks.push(chunk);
}
});
request.on("end", () => {
if (settled) {
return;
}
if (tooLarge) {
fail(new HttpError(413, "Request body must be 16 KiB or smaller"));
return;
}
let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(
Buffer.concat(chunks)
);
} catch {
fail(new HttpError(400, "Request body must be valid UTF-8"));
return;
}
try {
const value = JSON.parse(text);
settled = true;
resolve(value);
} catch {
fail(new HttpError(400, "Request body must contain valid JSON"));
}
});
request.on("aborted", () => {
fail(new HttpError(400, "Request body was interrupted"));
});
request.on("error", () => {
fail(new HttpError(400, "Could not read request body"));
});
});
}
async function handleRequest(request, response) {
try {
const requestUrl = new URL(
request.url ?? "/",
`http://${hostname}:${port}`
);
if (requestUrl.pathname === "/health") {
if (request.method !== "GET") {
request.resume();
sendJson(response, 405, { error: "Method not allowed" }, {
"Allow": "GET",
});
return;
}
sendJson(response, 200, { status: "ok" });
return;
}
if (requestUrl.pathname !== "/greet") {
request.resume();
sendJson(response, 404, { error: "Route not found" });
return;
}
if (request.method !== "POST") {
request.resume();
sendJson(response, 405, { error: "Method not allowed" }, {
"Allow": "POST",
});
return;
}
const contentType = request.headers["content-type"] ?? "";
const [mediaType, ...parameters] = contentType
.split(";")
.map(parameter => parameter.trim());
const charset = parameters
.find(parameter => parameter.toLowerCase().startsWith("charset="))
?.slice("charset=".length)
.replace(/^["']|["']$/g, "")
.toLowerCase();
if (
mediaType.toLowerCase() !== "application/json" ||
(charset && charset !== "utf-8")
) {
request.resume();
sendJson(response, 415, {
error: "Content-Type must be application/json with UTF-8",
});
return;
}
const body = await readJsonBody(request);
if (typeof body !== "object" || body === null || Array.isArray(body)) {
sendJson(response, 400, { error: "JSON body must be an object" });
return;
}
if (typeof body.name !== "string") {
sendJson(response, 400, { error: "name must be a string" });
return;
}
const name = body.name.trim();
if (name.length === 0 || [...name].length > MAX_NAME_CODE_POINTS) {
sendJson(response, 400, {
error: "name must contain 1 to 80 Unicode code points",
});
return;
}
sendJson(response, 200, { message: `Hello, ${name}!` });
} catch (error) {
if (response.destroyed) {
return;
}
if (error instanceof HttpError) {
sendJson(response, error.statusCode, { error: error.message });
return;
}
console.error("Unexpected request handler error:", error);
sendJson(response, 500, { error: "Internal server error" });
}
}
const server = createServer(handleRequest);
server.listen(port, hostname, () => {
console.log(`Server listening at http://${hostname}:${port}`);
});
2. Run the server and send JSON
Start the server in one terminal:
node server.mjs
In a second terminal, send a JSON object. PowerShell users can use curl.exe; macOS and Linux users can use curl.
curl.exe -i -X POST http://127.0.0.1:3000/greet `
-H "Content-Type: application/json; charset=utf-8" `
--data-binary '{"name":"Sam"}'
curl.exe -i -X POST http://127.0.0.1:3000/greet `
-H "Content-Type: application/json" `
--data-binary '{"name":"café"}'
The first request returns 200 and {"message":"Hello, Sam!"}. The second demonstrates that UTF-8 text can be carried in a JSON body when the client sends it as UTF-8.
To try the same examples in macOS or Linux, replace each PowerShell continuation backtick with a backslash and use curl instead of curl.exe.
3. Check the expected errors
- Send malformed JSON such as
{"name":}: the server returns400. - Send
[],null, or{"name":42}: the body or field validation returns400. - Omit the content type or send
text/plain: the server returns415 Unsupported Media Type. - Send more than 16 KiB: the server returns
413 Payload Too Largeafter draining the request without keeping the oversized body in the chunk array. - Use GET on
/greet: the server returns405withAllow: POST. An unknown path returns404.
4. Understand the checks
- Route and method checks happen before the body is parsed. Unsupported methods receive an
Allowheader. - The content type is checked before reading the accepted JSON. Unsupported bodies are drained without being stored.
- Each incoming chunk is counted in bytes. Character count is not a substitute for a network-byte limit because UTF-8 characters can take multiple bytes.
TextDecoderwithfatal: truerejects malformed UTF-8, andJSON.parserejects invalid JSON syntax.- Parsing is not application validation. The endpoint separately checks that the result is an object and that
nameis a non-blank string within the stated limit.
Common debugging checks
- 415 response: include
Content-Type: application/json; form-encoded data is not JSON. - 400 response: check JSON quotes, commas, braces, and that
nameis a string rather than a number or null. - Unexpected text: send the request body as UTF-8 and avoid manually labeling another charset as UTF-8.
- 413 response: the body exceeds the example's fixed 16 KiB cap. This small endpoint intentionally does not accept file uploads or larger documents.
- Connection refused: make sure
node server.mjsis still running on127.0.0.1:3000.
Security and production limits
This is a local learning example, not a production-ready API. It does not authenticate callers, persist records, provide TLS, implement rate limiting, or define a complete logging and monitoring policy. It binds to loopback only; do not expose it directly to the public internet.
Keep request limits appropriate to the application, validate every field at the server boundary, and avoid logging request bodies that may contain personal or confidential data. A successful JSON parse proves only that the bytes form JSON; it does not make the values safe or trustworthy.