Not every body is JSON. express.raw() hands you a Buffer and express.text() a string, with the same limit, type and verify options as express.json() and a narrow default type — application/octet-stream and text/plain — so widen type when you mean something else. Sometimes you should not buffer at all: req is a readable stream, so a large upload can be hashed as it arrives, one chunk in memory at a time.
app.post('/raw', express.raw({ type: 'application/octet-stream', limit: '2mb' }),
(req, res) => res.json({ isBuffer: Buffer.isBuffer(req.body),
bytes: req.body.length }));
app.post('/text', express.text({ type: 'text/*' }), (req, res) =>
res.json({ type: typeof req.body, first: req.body.split('\n')[0] }));
app.post('/upload', (req, res, next) => { // no parser: read the stream
const hash = crypto.createHash('sha256');
let bytes = 0;
req.on('data', (c) => { bytes += c.length; hash.update(c); });
req.on('end', () => res.json({ bytes, sha256: hash.digest('hex').slice(0, 16) }));
req.on('error', next); // a dropped connection must not hang the request
});$ curl -sX POST --data-binary @blob.bin -H "Content-Type: application/octet-stream" .../raw
{"isBuffer":true,"bytes":2048}
$ curl -sX POST --data-binary @books.csv -H "Content-Type: text/csv" .../text
{"type":"string","first":"id,title,year"}
$ curl -sX POST --data-binary @blob.bin .../upload
{"bytes":2048,"sha256":"2211c24ea0bfdaa4"}Mount parsers per route, not globally: a parser buffers the whole body before your handler runs, so a 2 MB limit applied everywhere is 2 MB of heap any caller can claim. That is also why the third route must not sit behind one — a parser that matched has already consumed the stream, and the data events never arrive.
Writing to a file is the same shape with stream.pipeline from node:stream/promises, which propagates errors and destroys both streams on failure (Piping Safely with pipeline). Streaming gives up the one thing a parser does for free — limit — so count bytes and call req.destroy() past your ceiling.