Parsing Request Bodies
Unlike the URL and headers, an HTTP request body does not arrive in one piece — the req object is a readable stream, and the body shows up as a series of data chunks that you must collect yourself before you can parse anything. There is no req.body in the core http module; that convenience belongs to frameworks like Express, which simply do this collection for you. This page shows how to buffer the stream, decode JSON and URL-encoded forms, branch on Content-Type, guard against oversized payloads, and hand multipart uploads off to busboy.
Collecting the body from the stream
Because IncomingMessage is a stream, the canonical pattern is to listen for data events, accumulate the chunks, and resolve on end. Chunks arrive as Buffers, so collect them in an array and Buffer.concat() once at the end rather than concatenating strings as you go — that keeps multi-byte UTF-8 characters from being split across chunk boundaries.
import http from 'node:http';
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
const raw = await readBody(req);
console.log(`Received ${raw.length} bytes`);
res.end('ok\n');
});
server.listen(3000);
On modern Node you can skip the event wiring entirely and use async iteration, which reads cleaner and propagates errors through the await:
async function readBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
The request listener in the snippet above is
async, so an unhandled rejection fromreadBodywould crash the process. In real code wrap the body read intry/catchand respond with400on malformed input — never let a parse error escape the handler.
Parsing JSON
Once you have the raw bytes, decode them to a string with .toString('utf8') and feed the result to JSON.parse. The fragile part is not parsing — it is everything around it: an empty body, a wrong content type, or invalid JSON should all produce a clean 400 rather than a stack trace.
import http from 'node:http';
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405).end();
return;
}
try {
const raw = await readBody(req);
const data = raw.length ? JSON.parse(raw.toString('utf8')) : {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: data }));
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
server.listen(3000);
curl -s -X POST http://localhost:3000/ \
-H 'Content-Type: application/json' \
-d '{"name":"Ada","role":"engineer"}'
Output:
{"received":{"name":"Ada","role":"engineer"}}
Parsing URL-encoded forms
HTML forms submitted without enctype send application/x-www-form-urlencoded — the same key=value&key=value format as a query string. The URLSearchParams class parses it natively, including percent-decoding and repeated keys, with no third-party dependency.
const raw = await readBody(req);
const form = new URLSearchParams(raw.toString('utf8'));
const fields = Object.fromEntries(form); // single values
const tags = form.getAll('tag'); // repeated values as array
A request body of name=Ada&tag=node&tag=http yields fields of { name: 'Ada', tag: 'http' } and tags of ['node', 'http']. Use getAll whenever a field can repeat, since Object.fromEntries keeps only the last occurrence.
Branching on Content-Type
A robust endpoint inspects the Content-Type header and dispatches to the right parser. Note that the header can carry parameters such as ; charset=utf-8, so match on a prefix rather than an exact string.
async function parseBody(req) {
const type = (req.headers['content-type'] || '').split(';')[0].trim();
const raw = await readBody(req);
if (type === 'application/json') {
return raw.length ? JSON.parse(raw.toString('utf8')) : {};
}
if (type === 'application/x-www-form-urlencoded') {
return Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
}
return raw; // unknown type: hand back the raw Buffer
}
| Content-Type | Parser | Result shape |
|---|---|---|
application/json | JSON.parse | Object / array |
application/x-www-form-urlencoded | URLSearchParams | Object (or getAll for arrays) |
text/plain | .toString('utf8') | String |
multipart/form-data | busboy | Streamed fields + files |
Enforcing a size limit
An unbounded readBody is a denial-of-service vector: a client can stream gigabytes and exhaust your memory. Track the running byte count and destroy the request the moment it crosses a ceiling. Checking Content-Length up front is a useful fast path, but never trust it alone — enforce the limit on the actual bytes received.
async function readBody(req, limit = 1_000_000) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) {
const err = new Error('Payload too large');
err.statusCode = 413;
req.destroy();
throw err;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
Catch the error in the handler and respond with err.statusCode || 400. With a 1 MB limit, a larger upload is rejected before it is fully buffered.
Multipart uploads with busboy
File uploads use multipart/form-data, a format with boundary markers and mixed text/binary parts that is impractical to parse by hand. The busboy library streams it efficiently — you pipe the request into it and react to field and file events, so large files never sit fully in memory.
npm install busboy
import http from 'node:http';
import { createWriteStream } from 'node:fs';
import busboy from 'busboy';
const server = http.createServer((req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end();
const bb = busboy({ headers: req.headers, limits: { fileSize: 5_000_000 } });
bb.on('field', (name, value) => {
console.log(`Field ${name} = ${value}`);
});
bb.on('file', (name, stream, info) => {
console.log(`File ${info.filename} (${info.mimeType})`);
stream.pipe(createWriteStream(`./uploads/${info.filename}`));
});
bb.on('close', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ uploaded: true }));
});
req.pipe(bb);
});
server.listen(3000);
Output:
Field title = Vacation photo
File beach.jpg (image/jpeg)
Busboy’s limits option enforces per-file and per-field caps for you, so you get the same DoS protection as the manual size check without writing it yourself.
Best Practices
- Treat
reqas a stream — there is noreq.bodyin core Node; collect chunks andBuffer.concatthem before parsing. - Always wrap body parsing in
try/catchand return400on malformed JSON or forms instead of letting the handler reject. - Enforce a byte-size limit during collection and respond with
413; never trust theContent-Lengthheader alone. - Match
Content-Typeon its prefix so; charset=...parameters don’t break the comparison. - Use
URLSearchParams.getAll()for form fields that can repeat, since plain object conversion drops duplicates. - Stream multipart uploads with
busboyrather than buffering whole files, and use itslimitsto cap file sizes. - Skip body reads for
GET/HEADrequests and reject unexpected methods with405.