Skip to content
Node.js nd http 5 min read

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 from readBody would crash the process. In real code wrap the body read in try/catch and respond with 400 on 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-TypeParserResult shape
application/jsonJSON.parseObject / array
application/x-www-form-urlencodedURLSearchParamsObject (or getAll for arrays)
text/plain.toString('utf8')String
multipart/form-databusboyStreamed 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 req as a stream — there is no req.body in core Node; collect chunks and Buffer.concat them before parsing.
  • Always wrap body parsing in try/catch and return 400 on malformed JSON or forms instead of letting the handler reject.
  • Enforce a byte-size limit during collection and respond with 413; never trust the Content-Length header alone.
  • Match Content-Type on 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 busboy rather than buffering whole files, and use its limits to cap file sizes.
  • Skip body reads for GET/HEAD requests and reject unexpected methods with 405.
Last updated June 14, 2026
Was this helpful?