File Uploads (multer)

intermediate express multer uploads multipart

Express’s express.json() middleware can’t parse file uploads. Files come in as multipart/form-data, a format designed for mixed text + binary data. Multer is the standard tool for parsing it.

In simple language: a multipart request is a bunch of “parts” glued together with a boundary string, each part has its own headers. Multer carves it up so we get clean req.file and req.body objects.

The upload pipeline

Request → Multer → Handler
Client POST /upload
Content-Type: multipart/form-data; boundary=----abc
[avatar binary][caption: "Vacation"]

multer.single("avatar") parses the stream

req.file = { fieldname, originalname, mimetype, size, path/buffer }
req.body = { caption: "Vacation" }

Basic setup

npm install multer
import multer from "multer";

const upload = multer({ dest: "uploads/" });

app.post("/profile", upload.single("avatar"), (req, res) => {
  console.log(req.file);
  // {
  //   fieldname: 'avatar',
  //   originalname: 'cat.jpg',
  //   mimetype: 'image/jpeg',
  //   destination: 'uploads/',
  //   filename: '8d7f...',
  //   path: 'uploads/8d7f...',
  //   size: 50234
  // }
  console.log(req.body); // other form fields
  res.json({ uploaded: req.file.filename });
});

upload.single("avatar") says “expect one file in a form field called avatar”. For multiple files: upload.array("photos", 5) or upload.fields([{ name: "avatar", maxCount: 1 }, { name: "docs", maxCount: 3 }]).

Disk vs memory storage

This is the design decision that matters.

// Disk: writes to filesystem, req.file.path points to it
const diskStorage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, "uploads/"),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    cb(null, `${crypto.randomUUID()}${ext}`);
  },
});

// Memory: keeps file in RAM as a Buffer, req.file.buffer
const memoryStorage = multer.memoryStorage();
StorageWhen to useWatch out for
DiskLarge files, eventual persistenceDisk fills up, cleanup is on you
MemoryForwarding to S3, image processing, small filesOOM if files are big or concurrent uploads spike

The classic pattern for S3 uploads is memory storage → pipe the buffer straight to S3, never touch local disk.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "us-east-1" });
const upload = multer({ storage: multer.memoryStorage() });

app.post("/upload", upload.single("file"), async (req, res) => {
  await s3.send(new PutObjectCommand({
    Bucket: "my-app-uploads",
    Key: `${crypto.randomUUID()}-${req.file.originalname}`,
    Body: req.file.buffer,
    ContentType: req.file.mimetype,
  }));
  res.json({ ok: true });
});

Size limits and file filters

Always set limits. Always. Without them, someone uploads a 50GB file and your server cries.

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (!allowed.includes(file.mimetype)) {
      return cb(new Error("Only JPEG/PNG/WebP allowed"));
    }
    cb(null, true);
  },
});

When the limit trips, Multer throws a MulterError. Handle it with an error middleware:

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      return res.status(413).json({ error: "File too large (max 5MB)" });
    }
    return res.status(400).json({ error: err.message });
  }
  next(err);
});

Security gotchas

The MIME type from the client is not trustworthy. The Content-Type header is set by the browser based on the file extension — an attacker can rename evil.exe to cat.jpg and the mimetype will say image/jpeg.

For real validation, sniff the actual bytes:

npm install file-type
import { fileTypeFromBuffer } from "file-type";

app.post("/upload", upload.single("file"), async (req, res) => {
  const detected = await fileTypeFromBuffer(req.file.buffer);
  if (!detected || !["jpg", "png", "webp"].includes(detected.ext)) {
    return res.status(400).json({ error: "Not a valid image" });
  }
  // safe to proceed
});

Other rules:

  • Never use originalname as a path — it can contain ../ traversal or weird chars. Generate UUIDs.
  • Strip metadata for privacy (EXIF GPS data in photos). Use sharp to re-encode.
  • Store outside the web root if using disk storage, or files become directly accessible URLs.
  • Scan for malware if you accept arbitrary file types — ClamAV or a service like VirusTotal.

Quick reference

upload.single("avatar")                          // one file
upload.array("photos", 5)                        // up to 5 files, same field
upload.fields([{ name: "avatar" }, { name: "docs" }]) // multiple fields
upload.none()                                    // no files, just form fields
upload.any()                                     // anything — usually avoid