File Uploads and Downloads

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The facts were checked against the official documentation and the package repositories on 2026-10-29. Files are the part of a web API where mistakes become security incidents: a client chooses the file name, the declared type and the size, and every one of them is untrusted. The page covers the upload side with multer 2 (the official Express middleware for multipart/form-data) and with busboy (the parser underneath it), the rules for file names and storage locations, content verification, the download side with res.download, res.attachment and res.sendFile, streaming, and object storage in the cloud. The running example is the cover-image feature of the Bookshelf API that grows across this section. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. Body parsers for JSON and forms are in Body Parsing; the validation of the text fields that travel next to a file is in Validation and Input Handling.

Why uploads need their own middleware

express.json() and express.urlencoded() read the whole body, decode it and put the result in req.body. A file upload uses a different encoding, multipart/form-data, in which the body is a sequence of parts separated by a boundary string, each with its own headers (field name, optional file name, optional content type). Neither built-in parser understands it, so req.body stays undefined in Express 5 when a form is posted as multipart and no multipart middleware is installed. The browser sets the correct Content-Type header, including the boundary, only when the form declares enctype="multipart/form-data" (or when FormData is used with fetch; never set that header by hand, or the boundary is lost). A frequent error in older tutorials is a misspelt enctype such as multipart/formdata: the browser then falls back to URL-encoded data and the file is silently never sent.

Large files must not be buffered whole before being processed. A multipart parser therefore reads the stream incrementally and hands each file part to a destination (disk, memory or your own writable stream) while enforcing limits. Two packages do that in the Express ecosystem:

Package Role

multer (2.5.0)

Express middleware maintained under the expressjs organisation. Wraps busboy, populates req.file, req.files and req.body, offers disk and memory storage engines and a fileFilter hook.

busboy (1.x)

Low-level streaming parser used by multer. It emits events for each field and file and lets you pipe the file stream anywhere, which is the right tool when the destination is an object store or a processing pipeline.

multer 2

Install it with npm install multer. Multer 2 is the current major version and the only one that receives security fixes: the 1.x line carried denial-of-service vulnerabilities in its handling of malformed multipart requests, which were fixed in the 2.0 release in 2025. Upgrading from 1.x needs no code changes for typical usage; check the project’s release notes for the supported Node.js range before pinning it. Multer processes only requests whose content type is multipart/form-data; any other request passes through untouched, so a route that also accepts JSON must declare its own JSON parser.

Multer must be placed on the route that needs it, not globally, because every global instance would accept uploads on every endpoint, including the ones that were never designed to receive files. The configuration object creates a reusable instance, and the instance exposes five methods that decide which file fields are accepted:

Method Accepts Result on the request

upload.single('cover')

one file in the field cover

req.file

upload.array('photos', 5)

up to five files in the field photos

req.files (array)

upload.fields([{ name: 'cover', maxCount: 1 }, { name: 'samples', maxCount: 3 }])

several named file fields, each with its own maximum

req.files (object keyed by field name)

upload.none()

only text fields; any file part is rejected

req.body

upload.any()

any file in any field

req.files (array)

upload.any() accepts files under any field name and should be avoided on public endpoints: the explicit methods turn an unexpected field into a LIMIT_UNEXPECTED_FILE error instead of accepting it. The text fields of the same form arrive in req.body, as strings, and the multipart parser populates them before the handler runs. The fields must be validated like any other input.

The file object

Each uploaded file is described by an object whose properties depend on the storage engine:

Property Meaning

fieldname

name of the form field

originalname

name on the client’s computer; fully attacker controlled

encoding

transfer encoding reported by the client

mimetype

content type reported by the client; fully attacker controlled

size

size in bytes, counted by the parser

destination, filename, path

disk storage only: directory, name inside it and full path

buffer

memory storage only: the whole file in a Buffer

Only size is measured by the server. Everything else is a claim from the client.

Storage engines

Multer ships two engines. A third option is a custom engine (an object with _handleFile and _removeFile), which is how packages for S3 or other stores integrate; for new code, a busboy-based handler or a presigned upload (below) is usually clearer.

Disk storage

multer.diskStorage() writes each file to a directory. Both options receive the request, the file object and a callback, so the destination and the file name can depend on the request:

// upload-disk.mjs
import { randomUUID } from 'node:crypto';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import express from 'express';
import multer from 'multer';

// Outside any directory served by express.static
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR ?? './storage/covers');
await mkdir(UPLOAD_DIR, { recursive: true });

const ALLOWED_EXTENSIONS = new Map([
  ['image/jpeg', '.jpg'],
  ['image/png', '.png'],
  ['image/webp', '.webp'],
]);

const storage = multer.diskStorage({
  destination(req, file, cb) {
    cb(null, UPLOAD_DIR);
  },
  filename(req, file, cb) {
    // The stored name never contains anything the client sent
    const extension = ALLOWED_EXTENSIONS.get(file.mimetype) ?? '';
    cb(null, `${randomUUID()}${extension}`);
  },
});

export const uploadCover = multer({
  storage,
  limits: {
    fileSize: 2 * 1024 * 1024, // 2 MiB per file
    files: 1,
    fields: 5,
    fieldSize: 10 * 1024,
    parts: 10,
  },
  fileFilter(req, file, cb) {
    if (ALLOWED_EXTENSIONS.has(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname));
    }
  },
}).single('cover');

const app = express();

app.post('/books/:id/cover', (req, res, next) => {
  uploadCover(req, res, (err) => {
    if (err) return next(err);
    if (!req.file) {
      return res.status(400).json({ error: 'A cover image is required' });
    }
    res.status(201).json({
      id: req.params.id,
      cover: req.file.filename,
      size: req.file.size,
    });
  });
});

Points that the example illustrates and that cause real bugs:

  • When destination is a string passed to multer({ dest }) or to the storage, multer creates the directory; when it is a function, you must make sure the directory exists, as the example does with mkdir.

  • The default filename, when none is given, is a random 16-byte hexadecimal string without an extension. Most applications want an extension so the file can be served with the correct type; derive it from the verified type, not from originalname.

  • The limits object is not optional hardening: without it, a single request can send an unlimited number of files of unlimited size and fill the disk.

  • Multer does not delete files when a later validation fails. If the handler rejects the request after the upload (invalid text fields, unauthorised user), delete req.file.path yourself with fs.unlink.

Memory storage

multer.memoryStorage() keeps each file as a Buffer in req.file.buffer. It is convenient for small files that are immediately processed (an avatar resized by an image library, a CSV parsed and discarded) and dangerous for anything else, because every concurrent upload holds its whole file in the heap. Always combine it with a strict limits.fileSize and a small files count, and never use it for video or archive uploads.

// upload-memory.mjs
import express from 'express';
import multer from 'multer';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 512 * 1024, files: 1 },
});

const app = express();

app.post('/imports/books', upload.single('catalog'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'A catalog file is required' });
  }
  const text = req.file.buffer.toString('utf8');
  const lines = text.split('\n').filter(Boolean);
  res.json({ received: req.file.size, lines: lines.length });
});

Limits

All limits are optional in multer and unlimited unless stated, so a production configuration sets every one that applies. They are enforced while the stream is parsed, so an oversized request is rejected without being stored in full.

Option Meaning Default

fieldNameSize

maximum length of a field name, in bytes

100

fieldSize

maximum size of a text field value, in bytes

1 MiB

fields

maximum number of non-file fields

unlimited

fileSize

maximum size of one file, in bytes

unlimited

files

maximum number of file fields

unlimited

parts

maximum number of parts (fields plus files)

unlimited

headerPairs

maximum number of header key-value pairs per part

2000

A breached limit makes multer call next with a MulterError, whose code property identifies the limit:

code Cause

LIMIT_PART_COUNT

too many parts

LIMIT_FILE_SIZE

a file exceeded fileSize

LIMIT_FILE_COUNT

too many files

LIMIT_FIELD_KEY

field name too long

LIMIT_FIELD_VALUE

field value too long

LIMIT_FIELD_COUNT

too many text fields

LIMIT_UNEXPECTED_FILE

a file arrived in a field the route does not accept

These are client errors and should become 4xx responses. A MulterError has no status property, so without a mapping the default error handler would answer 500. The mapping belongs in the central error handler that is described in Error Handling:

// upload-errors.mjs
import multer from 'multer';

const STATUS_BY_CODE = {
  LIMIT_FILE_SIZE: 413,
  LIMIT_PART_COUNT: 413,
  LIMIT_FILE_COUNT: 413,
  LIMIT_FIELD_COUNT: 413,
  LIMIT_FIELD_KEY: 400,
  LIMIT_FIELD_VALUE: 413,
  LIMIT_UNEXPECTED_FILE: 400,
};

export function uploadErrorHandler(err, req, res, next) {
  if (res.headersSent) return next(err);
  if (err instanceof multer.MulterError) {
    return res.status(STATUS_BY_CODE[err.code] ?? 400).json({
      type: 'about:blank',
      title: 'Upload rejected',
      status: STATUS_BY_CODE[err.code] ?? 400,
      detail: err.message,
      field: err.field,
    });
  }
  next(err);
}

Limits inside multer complement, but do not replace, limits further out. A reverse proxy normally has its own maximum body size (for example client_max_body_size in nginx), and rejecting an oversized upload there is cheaper than in Node.js. Check the Content-Length header early only as a hint: chunked requests have none, and multer’s counting is the authoritative limit.

fileFilter

fileFilter(req, file, cb) runs for every file part before any byte is stored. Call cb(null, true) to accept, cb(null, false) to skip silently, or cb(error) to abort the whole request. Because it runs while the request is still being parsed, only the fields that arrived before the file are available in req.body, which is the reason to put text fields before files when building a form. The filter sees file.mimetype and file.originalname, both client-controlled, so it can reject obviously wrong uploads cheaply but can never prove a file is what it claims. The real check happens after the file is stored, as described in MIME sniffing and content verification.

File names and path traversal

file.originalname is whatever the client put in the filename parameter of the Content-Disposition header of the part. A hostile client sends ../../app/server.js, ..\\..\\windows\\system32\\x, an absolute path, a name with a null byte, a name with control characters or a name that is 10,000 characters long. Two older patterns are the source of most upload vulnerabilities:

  1. Using originalname to build the destination path, for example path.join(publicDir, file.originalname). A name that starts with ../ escapes the directory, and a name that matches an existing file overwrites it. When the target is a directory that the application serves or executes, a path traversal becomes remote code execution or stored cross-site scripting.

  2. Storing the upload inside a directory served by express.static. Anything stored there is served exactly as uploaded with a content type derived from its extension, so an uploaded .html or .svg runs script in your origin.

The rules that remove the class of bugs are simple:

  • Never use the client name on the file system. Generate the stored name with crypto.randomUUID() or random bytes, and keep the original name only as metadata in the database, after sanitising it for display.

  • If a client name must be kept for downloads, reduce it with path.basename, strip control characters and path separators, limit its length and normalise Unicode. path.basename alone is not enough on POSIX for a name that contains backslashes, which is why a whitelist is better than a blacklist.

  • Choose the extension from your own allow-list, based on the verified content type.

  • Store uploads outside any directory that is served statically or executed, and serve them through a route that sets the headers (Content-Type, Content-Disposition, X-Content-Type-Options: nosniff). The official security guidance also recommends keeping the application directory unwritable by the Node.js process whenever possible.

  • When joining paths from any user-influenced segment, resolve the result and verify that it still starts with the base directory.

// safe-names.mjs
import path from 'node:path';

const BASE_DIR = path.resolve('./storage/covers');

/** Display-only sanitisation of a client-provided file name. */
export function sanitiseDisplayName(name) {
  const lastSegment = String(name).split(/[\\/]/).pop() ?? '';
  const cleaned = lastSegment
    .normalize('NFKC')
    .replace(/[\u0000-\u001f\u007f"<>|:*?]/g, '')
    .replace(/^\.+/, '')
    .trim();
  return cleaned.slice(0, 120) || 'file';
}

/** Resolve a stored name inside the base directory or throw. */
export function resolveInside(baseDir, storedName) {
  const resolved = path.resolve(baseDir, storedName);
  if (!resolved.startsWith(baseDir + path.sep)) {
    const err = new Error('Invalid file path');
    err.status = 400;
    err.expose = true;
    throw err;
  }
  return resolved;
}

console.log(sanitiseDisplayName('../../etc/passwd')); // passwd
console.log(resolveInside(BASE_DIR, 'a1b2c3.png'));

The example above is a defence in depth. The strongest defence is the first rule: when nothing from the client reaches the file system, there is no path to traverse.

MIME sniffing and content verification

The Content-Type of a part and the extension of its name are both claims. A file named cover.png with the type image/png can contain an HTML page, a script or an executable. Two separate problems follow:

  1. The server stores something other than what the application expects (a PHP-style upload attack).

  2. A browser later guesses the type of the response when the headers are missing or ambiguous (MIME sniffing) and runs content as script. Serving user content with X-Content-Type-Options: nosniff and a correct Content-Type prevents the second problem.

Verification means reading the first bytes of the stored file (the magic bytes, such as 89 50 4E 47 for PNG or FF D8 FF for JPEG) and comparing the detected type to the allow-list. The file-type package does that detection, and the application then renames or deletes the file accordingly:

// verify-content.mjs
import { unlink } from 'node:fs/promises';
import { fileTypeFromFile } from 'file-type';

const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp']);

/**
 * Express middleware, placed after multer: rejects and deletes the
 * stored file when its real type is not an allowed image type.
 */
export async function verifyUploadedImage(req, res, next) {
  if (!req.file) return next();
  const detected = await fileTypeFromFile(req.file.path);
  if (!detected || !ALLOWED.has(detected.mime)) {
    await unlink(req.file.path).catch(() => {});
    const err = new Error('The file is not a supported image');
    err.status = 415;
    err.expose = true;
    return next(err);
  }
  req.file.verifiedMime = detected.mime;
  next();
}

Because Express 5 forwards the rejection of an async middleware to the error handlers, the function needs no try/catch around its own await calls. Magic bytes prove the container format, not the safety of what is inside: a valid PNG can still be a decompression bomb, and image libraries have had memory-corruption bugs. Re-encoding images with a maintained library (resizing to a fixed size also strips metadata such as GPS positions) is a stronger defence than inspecting headers, and antivirus scanning is the usual extra step for documents. SVG deserves a special mention: it is XML that can contain script, so either refuse it or serve it only with Content-Disposition: attachment from a separate origin.

Full request lifecycle for an upload, from the client to storage and back:

Table 1. Upload pipeline: each stage can reject the request
Stage Check

Reverse proxy

maximum body size and request timeout

Authentication

only signed-in users upload (before multer, so unauthenticated requests are not parsed)

multer limits

file size, number of files and fields, number of parts

fileFilter

early reject by the declared type and field name

Storage

random name outside the public directory

Verification

magic bytes, re-encoding, scanning

Response

store only the generated name; never echo the client path

Authentication before multer is the stage that is most often forgotten: any middleware ahead of the upload route that rejects the request prevents the parser from consuming the body. This order is also why authenticated upload routes should run their guard first, as covered in Authentication and Authorization.

Streaming uploads with busboy

Multer is built on busboy, a streaming multipart parser with no dependency on Express. Using it directly is the right choice when the file should never touch the local disk: it can be piped straight to object storage, through a hash function or into a virus scanner. The parser is created with the request headers and is itself a writable stream that the request is piped into. Each file part is exposed as a readable stream that must be consumed (piped somewhere or resumed), otherwise the parser stalls.

// upload-busboy.mjs
import { createWriteStream } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import busboy from 'busboy';
import express from 'express';

const UPLOAD_DIR = path.resolve('./storage/manuscripts');
await mkdir(UPLOAD_DIR, { recursive: true });

const app = express();

app.post('/manuscripts', (req, res, next) => {
  if (!req.is('multipart/form-data')) {
    return res.status(415).json({ error: 'Send multipart/form-data' });
  }

  const bb = busboy({
    headers: req.headers,
    limits: { files: 1, fields: 5, fileSize: 20 * 1024 * 1024 },
  });

  const fields = {};
  const saved = [];
  const tasks = [];

  bb.on('field', (name, value) => {
    fields[name] = value;
  });

  bb.on('file', (name, file, info) => {
    const storedName = randomUUID();
    const target = path.join(UPLOAD_DIR, storedName);
    let truncated = false;
    file.on('limit', () => {
      truncated = true;
    });
    const task = pipeline(file, createWriteStream(target)).then(async () => {
      if (truncated) {
        await unlink(target);
        const err = new Error('File too large');
        err.status = 413;
        err.expose = true;
        throw err;
      }
      saved.push({ field: name, storedName, clientName: info.filename });
    });
    tasks.push(task);
  });

  bb.on('error', next);
  bb.on('close', async () => {
    try {
      await Promise.all(tasks);
      res.status(201).json({ fields, saved });
    } catch (err) {
      next(err);
    }
  });

  req.pipe(bb);
});

Source: mscdex/busboy

The points to know when using busboy directly:

  • The file event receives the field name, the file stream and an info object (filename, encoding, mimeType). When the fileSize limit is hit, the stream stops emitting data, emits limit and its truncated property becomes true: the bytes already written are an incomplete file and must be discarded.

  • The field event receives the name, the value and an info object; fields and files can interleave in any order.

  • Busboy does not set limits for you. Pass limits explicitly, with the same keys as multer (fieldNameSize, fieldSize, fields, fileSize, files, parts, headerPairs).

  • If an error occurs midway, destroy the open streams and remove partial files; the example removes the file only when the limit is hit and should be completed with clean-up on the error event in production.

  • The parser decodes file names as latin1 unless the option defParamCharset: 'utf8' is set, which matters for names with accents; since the stored name is generated anyway, this affects only the display name.

  • A multipart/form-data request must not be passed through express.json() or any other body parser first, because the stream would already be consumed.

Sending files to the client

The response object has three methods for files, and the differences between them are about who chooses the name, the headers and the safety checks:

Method Sets Use for

res.sendFile(path, options, cb)

Content-Type from the extension, Content-Length, Last-Modified, ETag, Cache-Control, Range support

a file the browser should display or process (an image, a PDF shown inline)

res.download(path, filename, options, cb)

everything sendFile sets plus a Content-Disposition header of type attachment with the file name

a file the browser should save

res.attachment(filename)

Content-Disposition and Content-Type from the extension, without sending anything

a response that you stream or write yourself and that should be saved

All three delegate the transfer to the send package, which provides conditional requests (If-None-Match, If-Modified-Since), byte ranges and efficient streaming. This is the main reason to prefer them to a manual pipe from createReadStream to the response.

res.sendFile and the root option

res.sendFile requires an absolute path or a root option, and it throws an error otherwise. The root option is the secure way to serve a name that came from the request, because the send layer then refuses any path that escapes the root, answering with 403 or 404 instead of leaking a file:

// download-routes.mjs
import path from 'node:path';
import express from 'express';

const STORAGE_ROOT = path.resolve('./storage/covers');
const app = express();

// GET /covers/<generated name>
app.get('/covers/:name', (req, res, next) => {
  res.sendFile(
    req.params.name,
    {
      root: STORAGE_ROOT,
      dotfiles: 'deny',
      maxAge: '1d',
      headers: { 'X-Content-Type-Options': 'nosniff' },
    },
    (err) => {
      if (err) next(err);
    },
  );
});

// GET /books/:id/manuscript: always saved, never shown inline
app.get('/books/:id/manuscript', (req, res, next) => {
  const stored = `${req.params.id}.pdf`;
  res.download(
    stored,
    'bookshelf-manuscript.pdf',
    { root: path.resolve('./storage/manuscripts'), dotfiles: 'deny' },
    (err) => {
      if (err && !res.headersSent) next(err);
    },
  );
});

Recommendations drawn from the example:

  • Always handle the callback. A missing file produces an error (ENOENT becomes a 404 with err.status), and a client that aborts a download produces an error after the headers were sent, which must not trigger a second response: check res.headersSent as shown in Error Handling.

  • Pass dotfiles: 'deny' or 'ignore' explicitly when the name is influenced by the client. Dotfile handling is also the reason .well-known needs a special case in express.static.

  • Even with root, validate the identifier (a UUID, a numeric id) before using it as a name. The root check is the last line of defence, not the first.

  • Do not use res.sendFile with a path built from req.query or req.params and no root; it is the Node.js equivalent of the classic local-file-inclusion bug.

res.download and Content-Disposition

res.download sets Content-Disposition: attachment with the file name you pass as the second argument, and falls back to the base name of the path when you pass none. It uses the content-disposition package, which writes a name that is a valid HTTP token (such as report.pdf) unquoted, quotes a name with spaces or other separators, and for a name with non-ASCII characters adds an RFC 6266 / RFC 8187 filename* parameter next to a quoted ASCII fallback in which each non-ASCII character becomes ?, so a name such as Résumé.pdf reaches the browser intact (this is the 5.3.0 behaviour, described below). Quotes and control characters in the name are escaped by that package; the file name argument is still a response header, so never pass an unsanitised client name that contains new lines through any other header builder.

The options object takes the same keys as res.sendFile (maxAge, root, dotfiles, headers, lastModified, cacheControl, immutable, acceptRanges), and the optional headers can add X-Content-Type-Options or a stricter Cache-Control. Responses to authenticated downloads should usually carry Cache-Control: private so that shared caches do not store them.

res.attachment() is the lower-level form: it only sets the header and the content type, and you write the body:

// attachment-csv.mjs
import express from 'express';

const app = express();

app.get('/export/books.csv', (req, res) => {
  res.attachment('books.csv');
  res.write('id,title,author\n');
  res.write('1,Dune,Frank Herbert\n');
  res.end();
});

Express 5.3.0 upgraded content-disposition to ^2.0.1, which changes the header that res.download, res.attachment and res.sendFile emit: a file name that is a valid HTTP token is no longer wrapped in quotes. Both forms mean the same thing under RFC 6266, so browsers save the same file, but a test that asserts the exact header bytes must be updated:

# Express 5.2 and earlier: res.download(path, 'report.pdf') or res.attachment('books.csv')
Content-Disposition: attachment; filename="report.pdf"
Content-Disposition: attachment; filename="books.csv"

# Express 5.3.0 and later
Content-Disposition: attachment; filename=report.pdf
Content-Disposition: attachment; filename=books.csv

Names that need quoting ("my report.pdf") keep their quotes. In a Supertest assertion, either expect the new form or parse the header with the parse function of content-disposition and compare the filename parameter, which works on both lines.

Streaming downloads

When the content is not a static file (a generated report, a database export, a file in object storage), stream it instead of building it in memory. Use stream.pipeline so that errors and client disconnects destroy both ends and do not leak file descriptors:

// stream-download.mjs
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import express from 'express';

const ROOT = path.resolve('./storage/archives');
const app = express();

app.get('/archives/:name', async (req, res) => {
  const file = path.resolve(ROOT, req.params.name);
  if (!file.startsWith(ROOT + path.sep)) {
    return res.status(400).json({ error: 'Invalid name' });
  }

  const info = await stat(file); // rejection reaches the error handler (Express 5)
  res.set({
    'Content-Type': 'application/octet-stream',
    'Content-Length': String(info.size),
    'X-Content-Type-Options': 'nosniff',
  });
  res.attachment(path.basename(file));

  try {
    await pipeline(createReadStream(file), res);
  } catch (err) {
    // The client went away or the disk failed: headers are gone, so just stop
    if (!res.headersSent) throw err;
  }
});

A hand-written stream does not implement conditional requests or byte ranges: a browser that wants to resume a large download or seek in a video sends Range, and only res.sendFile and res.download answer with 206 Partial Content. For generated content, set Transfer-Encoding: chunked implicitly by omitting Content-Length, and apply backpressure by using pipeline rather than calling res.write in a loop. The Node.js side of streams, including backpressure and Readable.toWeb, is in Implementing Custom Streams.

Cloud object storage

Local disks do not survive container restarts and cannot be shared by several instances behind a load balancer, so production applications usually store uploads in an object store (Amazon S3, Google Cloud Storage, Azure Blob Storage or an S3-compatible service). There are two designs:

Design How it works Trade-off

Proxy through Express

The client uploads to Express, which streams the file to the store (busboy or multer with a custom engine).

Express can authenticate, validate and scan every byte, but the traffic and the open connections cost server capacity.

Presigned URL

Express authenticates the user, validates the metadata and returns a short-lived signed URL; the client sends the file directly to the store.

The API server handles no file bytes and scales independently; verification happens afterwards (a storage event, or a confirmation call).

The proxy design with the AWS SDK v3 pipes a busboy file stream into a multipart upload, which keeps the memory constant whatever the size:

// upload-to-s3.mjs
import { randomUUID } from 'node:crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import busboy from 'busboy';
import express from 'express';

// Region and credentials come from the environment or the runtime role
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET; // placeholder: your bucket name

const app = express();

app.post('/covers', (req, res, next) => {
  const bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: 5 * 1024 * 1024 } });
  const uploads = [];

  bb.on('file', (name, file, info) => {
    const key = `covers/${randomUUID()}`;
    const upload = new Upload({
      client: s3,
      params: {
        Bucket: BUCKET,
        Key: key,
        Body: file,
        ContentType: info.mimeType,
        ContentDisposition: 'attachment',
      },
    });
    file.on('limit', () => upload.abort());
    uploads.push(upload.done().then(() => key));
  });

  bb.on('error', next);
  bb.on('close', async () => {
    try {
      res.status(201).json({ keys: await Promise.all(uploads) });
    } catch (err) {
      next(err);
    }
  });

  req.pipe(bb);
});

The presigned design needs only a signature route; the file never crosses the API:

// presign-upload.mjs
import { randomUUID } from 'node:crypto';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import express from 'express';

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET;
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp']);

const app = express();
app.use(express.json());

app.post('/covers/presign', async (req, res) => {
  const { contentType } = req.body ?? {};
  if (!ALLOWED.has(contentType)) {
    return res.status(400).json({ error: 'Unsupported content type' });
  }
  const key = `covers/${randomUUID()}`;
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType }),
    { expiresIn: 300 },
  );
  res.status(201).json({ key, url, expiresIn: 300 });
});

Whichever design is used:

  • Keep the bucket private and download through short-lived signed URLs or an authenticated Express route that streams the object. A public-read bucket is the cloud version of storing uploads in public/.

  • Give the application role only the permissions it needs (put on one prefix, get on the same prefix) and never place access keys in the repository; use the platform’s role or a secret manager.

  • Presigned PUT URLs cannot enforce a maximum size by themselves; use presigned POST policies (@aws-sdk/s3-presigned-post) with a content-length-range condition when the size matters, or verify after the fact.

  • Store the object key and the original display name in the database, and delete the object when the record is deleted.

  • Cloud storage replaced the image hosting services that older tutorials used for uploaded images; several of those services have since changed their free plans or shut down, so avoid depending on a particular free tier.

Bookshelf: cover images

Putting the pieces together, the cover route of the Bookshelf API authenticates first, then parses with strict limits, verifies the content, and records only generated names. The router wires the middleware from this page:

// covers-router.mjs
import express from 'express';
import { uploadCover } from './upload-disk.mjs';
import { verifyUploadedImage } from './verify-content.mjs';

export const covers = express.Router();

function requireUser(req, res, next) {
  // Placeholder: replace with the session or JWT check of the application
  if (!req.get('authorization')) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

function parseCover(req, res, next) {
  uploadCover(req, res, next);
}

covers.post('/books/:id/cover', requireUser, parseCover, verifyUploadedImage, (req, res) => {
  res.status(201).json({ id: req.params.id, cover: req.file.filename, type: req.file.verifiedMime });
});

What changed recently

  • Multer 2 is the maintained line (the current npm version is 2.5.0). The 1.x line, which most tutorials install, had denial-of-service vulnerabilities that were fixed only in 2.0.

  • Express 5 forwards rejected promises from async middleware, so the verification and download handlers above need no wrapper such as express-async-errors.

  • connect-multiparty, a middleware that older tutorials used for uploads, is deprecated (blog post of 2025-05-16); use multer or busboy instead.

  • res.sendFile and res.download in Express 5 use send 1.x, and the removed res.sendfile (lower case) of Express 3 and 4 must be replaced with res.sendFile.

  • Express 5.3.0 moved to content-disposition 2, so Content-Disposition no longer quotes file names that are valid HTTP tokens (filename=report.pdf instead of filename="report.pdf"); tests that compare the exact header must be updated, as shown in res.download and Content-Disposition.

  • Express 5 requires Node.js 18 or later, so streams, node:stream/promises, fetch and crypto.randomUUID() can be used without polyfills.

Where the books are outdated

The books consulted for this section show uploads in the style of Express 4 and Node.js 6. The points below are about what changed, not a reproduction of their listings:

Older practice Current practice

Form posted with a misspelt multipart enctype, so no file ever arrives

Declare enctype="multipart/form-data" exactly, or use FormData with fetch and leave the header to the browser.

The stored path built from the client’s originalname inside the public folder

Path traversal and overwrite. Generate names, store outside public/, serve through a route, see File names and path traversal.

Trusting file.mimetype as proof of type

Client claim only; verify magic bytes and re-encode images, see MIME sniffing and content verification.

multer 0.x or 1.x with no limits

Multer 2 with explicit limits, fileFilter and an error handler for MulterError.

Callback-style handlers with if (err) res.send(err) and no return

Express 5 promise forwarding, a central error handler and res.headersSent checks.

Uploaded images sent to a third-party image hosting account

Private object storage with presigned URLs or a streamed upload, see Cloud object storage.

res.send(status, body) and res.sendfile

Removed in Express 5: res.status(code).send(body) and res.sendFile with an absolute path or root.

method-override for forms that need PUT or DELETE

Still available (see Official Middleware Modules), but browser forms can use fetch with the real method.

Common pitfalls

  • Registering upload.single() globally with app.use, so every route parses and stores files.

  • Putting body-parser or logging middleware that reads the stream before multer, leaving multer an empty stream.

  • Forgetting that req.body text fields in a multipart request are all strings and are available only after multer ran.

  • Checking req.file in a route that was not given a file: req.file is undefined when the request carried no file, and that is a 400, not a 500.

  • Leaving uploaded files on disk when a later validation fails.

  • Using MemoryStorage with large or unbounded files, which exhausts the heap under concurrency.

  • Serving uploads from express.static with their original extensions.

  • Setting the Content-Type header manually in fetch or axios for FormData, which drops the multipart boundary.

  • Streaming a download without handling the client abort, which leaves open file descriptors; use pipeline.