TeachyLeaf library

What are you looking for?

Search practical tutorials and browser-based tools.

Node.js · HTTP · JSON · Beginner

Read and validate JSON request bodies in Node.js

Extend a small HTTP server with a bounded POST route. Check the content type, UTF-8, JSON syntax, and application fields before responding.

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 returns 400.
  • Send [], null, or {"name":42}: the body or field validation returns 400.
  • Omit the content type or send text/plain: the server returns 415 Unsupported Media Type.
  • Send more than 16 KiB: the server returns 413 Payload Too Large after draining the request without keeping the oversized body in the chunk array.
  • Use GET on /greet: the server returns 405 with Allow: POST. An unknown path returns 404.

4. Understand the checks

  1. Route and method checks happen before the body is parsed. Unsupported methods receive an Allow header.
  2. The content type is checked before reading the accepted JSON. Unsupported bodies are drained without being stored.
  3. 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.
  4. TextDecoder with fatal: true rejects malformed UTF-8, and JSON.parse rejects invalid JSON syntax.
  5. Parsing is not application validation. The endpoint separately checks that the result is an object and that name is 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 name is 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.mjs is still running on 127.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.

References

Continue learning

Compare a JSON body with the query string in the Node.js HTTP server lesson, then trace how Laravel routes requests in the request lifecycle lesson.