Receiving a file is a resource-management operation before it is a convenient form feature. A client controls its body bytes and declared metadata, so the server must define maximum size, accepted format, storage naming, and failure cleanup. This Node 24 guide implements a small raw-body upload endpoint using application/octet-stream. It deliberately does not pretend that splitting a multipart body on a boundary is a complete parser. The main fixture stores one tiny accepted upload in a fresh temporary directory and rejects a larger one. A separate stream exercise demonstrates cleanup when a write fails after partial data has arrived.
Define the transport contract and size policy
A raw upload endpoint can accept one file's bytes directly as the request body. A multipart/form-data endpoint instead contains fields, headers, and possibly several files inside a structured body. Those are different contracts. Use a maintained multipart parser with appropriate limits when that format is required; boundary markers can span chunks, and simplistic string splitting can corrupt binary data or misinterpret headers. The raw fixture avoids those parsing questions by accepting exactly one bounded byte body.
Content-Length can reject a declared oversized input early, but count actual bytes too. Some requests do not supply the header, and application policy must not depend solely on a client's declaration. Set a maximum file size, maximum request duration, and concurrency limits. A small per-file limit does not bound total memory if thousands of uploads are accepted at once. The fixture accumulates at most eight bytes per request, suitable for its teaching payload. Large production uploads should stream to a controlled sink while maintaining byte accounting.
Separate client names from storage paths
A submitted filename is metadata, not a trustworthy filesystem path. It can contain directory traversal syntax, reserved names, unusual Unicode, or names colliding with existing files. Generate an opaque server-controlled storage identifier and store the original display name separately only if the product needs it. Do not build a write path by directly joining an upload directory with unvalidated client text. A filename extension is also not reliable proof of a file's format.
The fixture uses randomUUID for the stored name and writeFile with the exclusive wx flag, preventing accidental replacement if a collision occurs. It does not place accepted files in a public static directory. A production storage service should record ownership, authorization, retention, and an identifier-to-storage mapping. Returning an identifier is different from returning an absolute server path, which can disclose deployment details. Also apply appropriate filesystem or object-storage access controls; a safe-looking generated name does not by itself authorize who can later download the file.
Validate content and publish only complete uploads
Content-Type is a client's declaration. It is useful for choosing a documented request contract but does not prove the body actually contains that media format. If the product accepts images or PDFs, inspect the content through appropriate validation and processing, set resource limits on that processing, and define any scanning requirements. Authentication and authorization belong before costly storage work where feasible. An accepted byte count is not a complete file safety verdict.
Write into a temporary or uncommitted state and publish the final storage record only after the transfer and required validation succeed. On interruption, timeout, pipeline error, or validation failure, remove the owned partial file. Avoid leaving orphaned data when the database record fails after a file write, and design a reconciliation policy for distributed storage. The main fixture uses tiny bounded buffers and writes only after complete receipt. The separate exercise streams directly to disk to expose the partial-write cleanup problem that a larger endpoint would need to manage.
Choose an explicit oversize and shutdown behavior
Once an upload exceeds the limit, stop retaining its bytes immediately. The server must also choose whether to drain and reject the request or terminate the connection, considering resource costs and whether the client can receive a clean error response. Abruptly destroying the request socket can prevent the client from receiving a 413 response. Draining indefinitely is also unsafe, so enforce time and bandwidth or connection policies appropriate to the deployment.
The teaching collector discards later chunks once its tiny limit is exceeded and returns 413 after the finite controlled body ends. Its server request timeout is configured, but a production service needs a fuller policy for slow uploads, total duration, reverse-proxy limits, and concurrent requests. Keep shutdown coordinated with active uploads: stop new work, allow or cancel existing transfers according to policy, close writers, and remove abandoned partial files. Do not call a fixture production-ready solely because it successfully accepted one buffer. Its value is a concrete implementation of the declared small contract.
Node.js documentation and version notes
Node.js Bounded File Uploads Examples
Each example shows the code, the output it produces and what each part does.
Accepting and rejecting uploads by byte limit
Run this .mjs fixture with Node 24. It starts a local upload endpoint and a temporary storage directory. The first request sends six bytes and is stored under a server-generated name. The second sends ten bytes, exceeding the eight-byte maximum, and receives 413. The final directory count confirms that only the accepted request created a file. Exact random names are intentionally absent from the output. The collector bounds retained bytes and distinguishes a type mismatch from an oversize body. Finally closes the server and removes only the generated fixture directory. No existing project or user file participates in this test.
import http from 'node:http';
import { once } from 'node:events';
import { randomUUID } from 'node:crypto';
import { mkdtemp, writeFile, readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const dir = await mkdtemp(join(tmpdir(), 'upload-demo-'));
const limit = 8;
const server = http.createServer(async (req, res) => {
try {
if (req.method !== 'POST' || req.url !== '/uploads') { req.resume(); res.writeHead(404); res.end(); return; }
if (req.headers['content-type'] !== 'application/octet-stream') { req.resume(); res.writeHead(415); res.end(); return; }
const chunks = [];
let size = 0;
let oversized = false;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) { oversized = true; chunks.length = 0; }
if (!oversized) chunks.push(chunk);
}
if (oversized) { res.writeHead(413); res.end('Too large'); return; }
const id = randomUUID();
await writeFile(join(dir, id), Buffer.concat(chunks), { flag: 'wx' });
res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify({ id, bytes: size }));
} catch { if (!res.headersSent) res.writeHead(500); res.end('Upload failed'); }
});
server.requestTimeout = 2000;
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const url = 'http://127.0.0.1:' + server.address().port + '/uploads';
const accepted = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: Buffer.from('abcdef') });
console.log(accepted.status, (await accepted.json()).bytes);
const rejected = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: Buffer.from('abcdefghij') });
console.log(rejected.status); await rejected.text();
console.log('stored files', (await readdir(dir)).length);
} finally {
server.closeIdleConnections();
await new Promise(resolve => server.close(resolve));
await rm(dir, { recursive: true, force: true });
}201 6
413
stored files 1Node.js Bounded File Uploads in Real Projects
Remove a partial file after an oversize stream
Stream a seven-byte source through a six-byte limit into an owned partial file. The Transform counts every chunk before forwarding it and reports an error once the limit is exceeded. pipeline coordinates stream failure and completion. The catch removes the partial path, then prints the rejection and confirms that the directory is empty. This is a failure-path exercise, so leaving the first few written bytes behind is incorrect. The file name is fixed only inside a fresh private fixture directory. In a real upload flow, generate a controlled unique temporary path and commit it only after successful validation.
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { mkdtemp, rm, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const dir = await mkdtemp(join(tmpdir(), 'upload-partial-demo-'));
const path = join(dir, 'owned-partial');
let bytes = 0;
const limiter = new Transform({ transform(chunk, encoding, done) {
bytes += chunk.length;
if (bytes > 6) done(new Error('Upload too large'));
else done(null, chunk);
} });
try {
await pipeline(Readable.from([Buffer.from('abc'), Buffer.from('defg')]), limiter,
createWriteStream(path, { flags: 'wx' }));
} catch (error) {
await rm(path, { force: true });
console.log(error.message);
console.log('remaining files', (await readdir(dir)).length);
} finally { await rm(dir, { recursive: true, force: true }); }Upload too large
remaining files 0Node.js Bounded File Uploads FAQ
Why ignore a client filename when choosing a path?
A client can supply traversal syntax, collisions, or names with platform-specific meaning. Generate the storage path under server control and retain a display name only as separate validated metadata. An opaque identifier also avoids exposing internal absolute paths. Storage authorization still matters: a hard-to-guess name is not a substitute for checking who may access the uploaded object.
Does application/octet-stream prove the file is harmless?
No. It declares the raw-body transport contract, not a validated content format. A product that accepts specific document or image types needs appropriate content inspection and processing limits after receipt. Check ownership and access as well. A byte limit protects resources; it does not determine whether a file's contents are acceptable for the application's later use.
Can I parse multipart with a string split?
That is not a reliable parser. Binary bodies, chunk boundaries, part headers, and delimiter rules require proper handling. Use a suitable maintained parser with limits on files, fields, headers, and total size. This lesson intentionally implements a raw single-file endpoint so its body contract is simple. Changing the Content-Type label does not transform that implementation into a multipart receiver.
What should happen to a partial file after failure?
Remove the file owned by that upload before reporting completion, and ensure writers have been stopped or closed. The stream practice uses pipeline for error coordination and explicitly removes its generated partial path. A production system also needs cleanup for process crashes and storage/database inconsistencies. Publish the final object only after the required transfer and validation stages succeed.
Node.js Bounded File Uploads Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
How many files remain after the worked seven-byte upload exceeds its six-byte limit?
Node.js Bounded File Uploads Quiz
2 questions to check what you learned.
Why count actual request bytes as well as inspect Content-Length?
- The declared length is not the entire enforcement policy and may be absent
- Every request body always contains exactly one byte per character
- Content-Length automatically validates every uploaded format
Why not place every accepted upload directly in a public static directory?
- Acceptance and public download authorization are separate decisions
- randomUUID automatically grants safe public access
- A static directory prevents file processing errors