Skip to main content

How does body parsing work in Express.js?

Body parsing in Express.js reads the raw byte stream from an HTTP request body and converts it into a JavaScript object on req.body, based on the Content-Type header.

Theory

TL;DR

  • Without a parser, req.body is always undefined, even if the client sent data
  • express.json() handles application/json; express.urlencoded() handles HTML forms
  • Register parsers before route definitions, not after - streams are one-pass
  • Default size limit is 1mb; adjust with { limit: '10kb' } per route
  • multipart/form-data (file uploads) needs multer, not the built-in parsers

Quick example

js
const express = require('express'); const app = express(); // Parsers BEFORE routes app.use(express.json()); // application/json app.use(express.urlencoded({ extended: true })); // HTML forms app.post('/user', (req, res) => { console.log(req.body); // { name: 'Alice', age: 30 } res.json(req.body); }); app.listen(3000); // curl -X POST -H "Content-Type: application/json" \ // -d '{"name":"Alice","age":30}' http://localhost:3000/user

The middleware runs before your route handler. By the time your code reads req.body, the bytes are already parsed.

How it works internally

Node.js exposes the request body as a readable stream via req.on('data'). The express.json() middleware buffers incoming chunks up to the size limit (1mb by default), then checks the Content-Type header. If it matches application/json, it calls JSON.parse() on the concatenated UTF-8 string and attaches the result to req.body. If the JSON is malformed, it throws a 400 error before your route runs.

express.urlencoded() does the same but uses the qs library (when extended: true) or Node's built-in querystring module (when extended: false) to parse name=Alice&age=30 style strings.

Before Express 4.16, you needed the separate body-parser npm package. It ships built-in now, though body-parser is still importable for custom configurations.

When to use each parser

  • JSON API endpoints (/users, /products) - express.json()
  • HTML form submissions (login, register pages) - express.urlencoded({ extended: true })
  • File uploads (<form enctype="multipart/form-data">) - multer, not the built-ins
  • Stripe webhooks needing raw bytes for signature verification - express.raw({ type: '*/*' })
  • GET routes, static files - skip parsers entirely to save memory

The extended option

extended: false uses Node's querystring module, which only handles flat key-value pairs. extended: true uses the qs library, which parses nested objects and arrays:

js
// extended: false // Input: user[name]=Alice&user[age]=30 // Output: { 'user[name]': 'Alice', 'user[age]': '30' } <- flat strings // extended: true // Input: user[name]=Alice&user[age]=30 // Output: { user: { name: 'Alice', age: '30' } } <- proper nesting

For most real apps, extended: true is what you want.

Common mistakes

Mistake: parser registered after the route

js
app.post('/user', handler); // req.body = undefined here app.use(express.json()); // too late - stream already consumed

Streams are read once. The route runs first and the parser never sees the data. The parser order bug catches almost every Express developer at least once - you add logging everywhere, the network tab looks fine, but req.body stays undefined, and then you spot the app.use() call sitting three lines below the route. Move it to the top.

Mistake: missing Content-Type header on the client

js
// This sends data without telling the parser what format it is: fetch('/user', { method: 'POST', body: JSON.stringify(data) }); // Fix: fetch('/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });

The parser checks the header before touching the stream. No header match means req.body stays {} or undefined.

Mistake: using built-in parsers for file uploads

<form enctype="multipart/form-data"> sends multipart/form-data. The built-in parsers skip this format entirely. req.body will be {}. You need multer for file uploads.

Mistake: no size limit on untrusted input

js
app.use(express.json()); // 1mb default - fine for most APIs // For public-facing auth endpoints, be explicit: app.post('/login', express.json({ limit: '10kb' }), handler);

A client sending a 500mb payload buffers everything in memory before the parser rejects it. That is a straightforward denial-of-service vector. Set explicit limits for routes that accept untrusted data.

Mistake: extended: false with nested form data

js
app.use(express.urlencoded({ extended: false })); // Form: user[profile][name]=Alice // req.body = { 'user[profile][name]': 'Alice' } <- keys become strings, no nesting

Switch to extended: true if your forms send nested structures.

Real-world usage

  • React/Next.js API routes: express.json() for REST endpoints receiving fetch with JSON.stringify
  • Login and register pages: express.urlencoded({ extended: true }) for browser form submits
  • Stripe webhooks: express.raw({ type: '*/*' }) so you can verify the HMAC signature against the exact bytes before any parsing
  • File upload endpoints: multer with diskStorage or memoryStorage depending on whether you write to disk or stream to S3
  • Prisma/Drizzle mutations: parse the body, validate with Zod or Joi, then pass to the ORM

Follow-up questions

Q: What happens if you register express.json() twice?
A: Both parsers run in sequence. The first one buffers and parses the stream. The second one gets an empty stream, does nothing useful, but still spends CPU on the attempt. Register it once globally.

Q: What is the difference between express.json() and express.raw()?
A: express.json() parses the body and gives you a JS object. express.raw() gives you the raw Buffer with no parsing. Stripe webhook verification requires express.raw() because the HMAC signature is computed over the exact bytes, not the parsed object.

Q: Why does req.body stay undefined even after adding express.json()?
A: Three common causes: parser registered after the route, client not sending Content-Type: application/json, or the body is multipart/form-data which the parser skips.

Q: How did body parsing work before Express 4.16?
A: You installed body-parser as a separate package and called app.use(bodyParser.json()). Express 4.16 bundled it. The body-parser package still works and is still useful for per-route options or older setups.

Q: What are the memory implications of 1000 concurrent 1mb JSON requests on a 4GB server?
A: Each request buffers roughly 1mb of raw bytes plus the parsed object overhead - about 2mb total. 1000 concurrent requests hit roughly 2GB peak. For large payloads, process with streaming (req.on('data')) or use worker clusters. Monitor with process.memoryUsage() or clinic.js.

Examples

Basic: JSON API endpoint

js
const express = require('express'); const app = express(); app.use(express.json()); app.post('/register', (req, res) => { const { email, password } = req.body; // { email: 'user@example.com', password: 'secret' } console.log('Registering:', email); res.status(201).json({ message: 'User created' }); }); app.listen(3000);

express.json() runs before the route handler. By the time your code reads req.body, it is already a plain JS object.

Intermediate: Route-specific parsing with different limits

js
const express = require('express'); const app = express(); // Tight limit for auth endpoints app.post('/login', express.json({ limit: '10kb' }), (req, res) => { const { email, password } = req.body; res.json({ token: 'abc123' }); } ); // Stripe webhook needs raw bytes, not a parsed object app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['stripe-signature']; // req.body is a Buffer here - needed for HMAC verification console.log(Buffer.isBuffer(req.body)); // true res.sendStatus(200); } ); app.listen(3000);

Route-specific parsers let you apply different strategies per endpoint without one global config affecting everything.

Advanced: File upload with multer

js
const express = require('express'); const multer = require('multer'); const app = express(); app.use(express.json()); const upload = multer({ dest: 'uploads/', limits: { fileSize: 5 * 1024 * 1024 } // 5MB }); app.post('/avatar', upload.single('avatar'), (req, res) => { // req.file -> { fieldname, originalname, mimetype, size, path } // req.body -> other text fields from the same form console.log(req.file.originalname); // 'profile.jpg' res.json({ filename: req.file.filename }); }); app.listen(3000);

multer handles multipart/form-data that the built-in parsers skip. Text fields from the same form still land on req.body.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?