File Uploads in Express with Multer: Storage Engines, Filters, Limits and Sharp Thumbnails

Key takeaways

Multer is a Node.js middleware for handling multipart/form-data, primarily used for file uploads. It's built on top of busboy for maximum efficiency.

Introduction

Multer is a Node.js middleware for handling multipart/form-data, which is primarily used for uploading files. It’s built on busboy for high efficiency.

Why a Dedicated Middleware for File Uploads?

If you’ve worked with Express for any length of time, you already reach for express.json() and express.urlencoded() without thinking about it. Neither of those middlewares can parse a file upload, and understanding why explains why Multer exists as a separate package rather than a config flag on body-parser.

A normal JSON request has one content type, one encoding, and a body that’s small enough to buffer and JSON.parse() in one shot. A file upload is fundamentally different: the browser encodes the form as multipart/form-data, which packages an arbitrary number of parts - some are ordinary text fields, others are binary file streams - separated by a boundary string that’s unique to that request and declared in the Content-Type header (Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...). Parsing this format correctly means:

  • Reading the boundary out of the header and using it to split the raw request stream into parts.
  • Distinguishing a text field part from a file part by inspecting each part’s own Content-Disposition header.
  • Never buffering the entire file into memory by default, because a 2GB video upload would blow the process’s heap if you did.
  • Handling chunked transfer encoding, since the client often doesn’t know the total size up front.

Writing that parser by hand means dealing with raw streams, buffering partial boundary matches across TCP packet boundaries, and getting encoding edge cases right - the kind of code that’s easy to get almost correct and very hard to get fully correct. That’s the gap Multer fills. Under the hood it delegates the actual low-level parsing to busboy, a battle-tested streaming parser, and wraps it in an Express-friendly middleware API that populates req.file / req.files and req.body the way you’d expect.

Without Multer (manual parsing is painful):

// Complex manual parsing of multipart data
// Dealing with streams, boundaries, encoding...

With Multer:

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

app.post('/upload', upload.single('file'), (req, res) => {
  console.log(req.file); // File info
  res.send('File uploaded!');
});

The one-line multer({ dest: 'uploads/' }) call hides a fair amount of decision-making: where files land, how they’re named, what gets rejected, and what happens when something goes wrong. The rest of this guide walks through those decisions one at a time, because the defaults are fine for a weekend project but not for anything that will see real traffic.

Installation

npm install multer

Basic Usage

Multer exposes a handful of methods that each answer a different shape of upload: upload.single(fieldName) expects exactly one file under a specific form field, upload.array(fieldName, maxCount) expects multiple files under the same field name (think a photo gallery uploader where the user selects several files at once), and upload.fields([...]) expects multiple files spread across different named fields (an avatar plus a resume, for example). Picking the wrong one is a common source of confusing 500 errors - if the client sends an array but your route only wired up .single(), Multer throws a LIMIT_UNEXPECTED_FILE error rather than silently accepting the first file.

Single File Upload

const express = require('express');
const multer = require('multer');

const app = express();
const upload = multer({ dest: 'uploads/' });

app.post('/upload', upload.single('avatar'), (req, res) => {
  // req.file is the `avatar` file
  // req.body will hold text fields
  console.log(req.file);
  console.log(req.body);
  
  res.json({ file: req.file });
});

Notice that req.body is populated too. Multer parses the non-file fields of the multipart body as a side effect of consuming the stream, so form fields sent alongside the file (a caption, a category id, and so on) show up in req.body exactly as they would with express.urlencoded(). This is a frequent point of confusion for developers who add Multer to a route and then wonder why express.json() “stopped working” - it never ran, because the request’s Content-Type is multipart/form-data, not application/json, and Multer is now the middleware responsible for populating req.body on that route.

Multiple Files

// Multiple files with same field name
app.post('/photos', upload.array('photos', 12), (req, res) => {
  // req.files is array of `photos` files (max 12)
  console.log(req.files);
  res.json({ files: req.files });
});

// Multiple files with different field names
app.post('/profile', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'gallery', maxCount: 8 }
]), (req, res) => {
  // req.files is an object with keys 'avatar' and 'gallery'
  console.log(req.files.avatar);
  console.log(req.files.gallery);
  res.json({ files: req.files });
});

The maxCount argument in both forms isn’t just a UX nicety - it’s your first line of defense against abuse. Without it, a malicious or buggy client could attach hundreds of files to a single request and force your server to allocate a file handle (or a memory buffer) for each one before your route handler ever gets a chance to reject the request.

Storage Engine

The single most consequential decision you make with Multer is which storage engine to use, because it determines where the bytes of the uploaded file actually live while - and after - the request is being processed.

Disk Storage

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
  }
});

const upload = multer({ storage: storage });

diskStorage streams the incoming file straight to disk as it arrives, chunk by chunk, without ever holding the whole file in the Node.js process’s memory. That makes it the right default for anything where uploads can be large (video, PDFs, archives) or where you expect meaningful concurrency, because memory usage per upload stays roughly constant regardless of file size. The trade-off is that disk I/O is slower than memory access, the files now live on a local filesystem that you’re responsible for cleaning up, and - critically for anyone deploying to Cloudflare Pages, Vercel, AWS Lambda, or any other ephemeral/serverless runtime - “disk” in that environment is often a read-only or non-persistent filesystem, so diskStorage silently doesn’t work the way you expect in production even though it works fine on localhost.

Memory Storage

const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

app.post('/upload', upload.single('file'), (req, res) => {
  // req.file.buffer contains the file in memory
  console.log(req.file.buffer);
});

memoryStorage buffers the entire file into a Buffer in process memory and exposes it as req.file.buffer. This is convenient when you need the raw bytes immediately - piping them into Sharp for image processing, or streaming them onward to S3 without ever touching local disk - and it sidesteps the “no writable disk in production” problem entirely. The cost is real, though: every concurrent upload now consumes memory proportional to file size, for the entire duration of the request, and that memory isn’t released until the request finishes and the buffer is garbage-collected. Ten concurrent 50MB uploads without a size limit is 500MB of resident memory just for the buffers, on top of whatever else your process needs - a fast, unglamorous way to trigger an out-of-memory crash under load. As a rule of thumb: use memoryStorage only when you also set a conservative limits.fileSize, and prefer streaming approaches (disk storage, or a direct-to-S3 storage engine) once files can plausibly exceed a few megabytes.

flowchart LR
    A[Client multipart request] --> B{Storage engine}
    B -->|diskStorage| C[Streamed to disk\nconstant memory use]
    B -->|memoryStorage| D[Buffered in RAM\nmemory scales with file size]
    C --> E[req.file.path]
    D --> F[req.file.buffer]
    E --> G[Route handler /\ndownstream processing]
    F --> G

File Information

app.post('/upload', upload.single('file'), (req, res) => {
  console.log(req.file);
  // {
  //   fieldname: 'file',
  //   originalname: 'photo.jpg',
  //   encoding: '7bit',
  //   mimetype: 'image/jpeg',
  //   destination: 'uploads/',
  //   filename: 'file-1234567890.jpg',
  //   path: 'uploads/file-1234567890.jpg',
  //   size: 12345
  // }
});

Two of these fields deserve extra scrutiny before you use them anywhere sensitive: originalname and mimetype are both taken directly from the request as sent by the client, unverified. originalname is whatever filename the browser reported - it can contain path separators, null bytes, or shell metacharacters if the request was crafted by hand rather than a real browser. mimetype is copied from the Content-Type header of that part, which the client sets, not something Multer (or busboy) derives by inspecting the actual bytes. Both are covered in more depth in the Security section below, but the short version is: never build a filesystem path or a security decision directly from either field without sanitizing or independently verifying it first.

File Filtering

const fileFilter = (req, file, cb) => {
  // Accept images only
  if (file.mimetype.startsWith('image/')) {
    cb(null, true);
  } else {
    cb(new Error('Only images are allowed!'), false);
  }
};

const upload = multer({
  dest: 'uploads/',
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
  }
});

// Specific file types
const imageFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
  
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type. Only JPEG, PNG and GIF are allowed.'));
  }
};

fileFilter runs before the file body has been fully consumed - it only has access to the part’s headers (fieldname, originalname, encoding, mimetype), not the file’s actual content. That’s an intentional performance trade-off: rejecting a file based on its declared type lets Multer abort the stream early instead of buffering or writing bytes it’s about to throw away. The catch is exactly what was flagged above - file.mimetype is client-supplied and trivially spoofed. Renaming payload.php to payload.jpg and setting the multipart Content-Type to image/jpeg sails straight through both fileFilter examples above. This matters most on endpoints that serve uploaded files back to other users (avatars, attachments) or that later execute or interpret the uploaded content in any way.

If you need a real guarantee about file type rather than a client’s claim, you have to inspect the bytes after the fact - typically the first few bytes, or “magic numbers” (\xFF\xD8\xFF for JPEG, \x89PNG for PNG, and so on). Libraries like file-type do this for you and work well paired with memoryStorage, since you already have the buffer in hand:

const { fileTypeFromBuffer } = require('file-type');

app.post('/upload', upload.single('image'), async (req, res) => {
  const detected = await fileTypeFromBuffer(req.file.buffer);
  const allowed = ['image/jpeg', 'image/png', 'image/gif'];

  if (!detected || !allowed.includes(detected.mime)) {
    return res.status(400).json({ error: 'File content does not match an allowed image type' });
  }

  // Safe to proceed - the file's actual bytes were verified, not just its header claim
  res.json({ ok: true });
});

Limits Configuration

const upload = multer({
  dest: 'uploads/',
  limits: {
    fileSize: 5 * 1024 * 1024,  // 5MB
    files: 10,                   // Max 10 files
    fields: 20,                  // Max 20 non-file fields
    fieldNameSize: 100,          // Max field name size
    fieldSize: 1024 * 1024,      // Max field value size (1MB)
  }
});

This block is easy to skip during development and easy to forget in production, but it’s arguably the most important defense against denial-of-service on any upload endpoint. Without an explicit fileSize limit, Multer will happily accept a multi-gigabyte upload and write (or buffer) every byte of it - a single client, deliberately or accidentally, can exhaust your disk space or process memory with one request, and a handful of concurrent requests can do it faster. The same logic applies to files and fields: an attacker who sends a request with ten thousand small text fields, or a thousand tiny file parts, can degrade your server even if no individual part is large. Set every limit that’s relevant to your endpoint rather than relying on fileSize alone, and size them to the smallest values your legitimate use case actually needs - a profile-photo endpoint has no business accepting a 500MB file or twenty files in one request.

Error Handling

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ file: req.file });
});

// Error handling middleware
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    // Multer error
    if (err.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({ error: 'File too large' });
    }
    if (err.code === 'LIMIT_FILE_COUNT') {
      return res.status(400).json({ error: 'Too many files' });
    }
    if (err.code === 'LIMIT_UNEXPECTED_FILE') {
      return res.status(400).json({ error: 'Unexpected field' });
    }
  } else if (err) {
    // Custom error
    return res.status(400).json({ error: err.message });
  }
  
  next();
});

A limits violation, or a rejection from fileFilter, doesn’t throw synchronously inside your route handler - it’s passed to Express’s error-handling chain via next(err), which is why the four-argument error middleware above (not a regular route) is where you actually catch it. This trips people up the first time: wrapping upload.single('file') in a try/catch inside your route handler does nothing, because by the time your handler code runs, Multer has already succeeded or already called next(err) - there’s no synchronous throw to catch.

multer.MulterError carries a code property with one of several well-defined values worth handling explicitly rather than falling through to a generic 400:

  • LIMIT_PART_COUNT - too many parts (fields + files combined) in the multipart body.
  • LIMIT_FILE_SIZE - a single file exceeded limits.fileSize.
  • LIMIT_FILE_COUNT - more files were sent than limits.files allows.
  • LIMIT_FIELD_KEY - a field name was longer than limits.fieldNameSize.
  • LIMIT_FIELD_VALUE - a field’s value was longer than limits.fieldSize.
  • LIMIT_FIELD_COUNT - more non-file fields than limits.fields allows.
  • LIMIT_UNEXPECTED_FILE - a file arrived under a field name that wasn’t configured (for example, sending a file to a .fields() route under a name you didn’t list).

Mapping each of these to a specific, actionable message (rather than a blanket “Upload failed”) makes a real difference for API consumers and for your own debugging later - LIMIT_UNEXPECTED_FILE in particular almost always indicates a mismatch between the client’s form field name and what the server-side route configured, which is otherwise a frustrating thing to diagnose from a generic error.

Image Processing with Sharp

npm install sharp
const sharp = require('sharp');

const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

app.post('/upload', upload.single('image'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' });
    }
    
    // Resize and optimize
    const filename = `${Date.now()}-optimized.jpg`;
    
    await sharp(req.file.buffer)
      .resize(800, 600, { fit: 'inside' })
      .jpeg({ quality: 80 })
      .toFile(`uploads/${filename}`);
    
    res.json({
      message: 'Image uploaded and optimized',
      filename: filename
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

Pairing memoryStorage with Sharp is a common and reasonable pattern because it avoids an extra disk round-trip: the buffer goes straight from the multipart parser into Sharp’s processing pipeline. There’s a useful security side effect here too - re-encoding an image (resizing and re-saving as JPEG, as above) discards anything in the original file that wasn’t a valid part of the image data. Malformed or intentionally crafted image files that try to smuggle a polyglot payload (bytes that are simultaneously a valid image and a valid script or executable elsewhere in the file) generally don’t survive a genuine re-encode, because Sharp is reading and reconstructing pixel data rather than copying bytes through. This isn’t a substitute for the MIME/type validation covered earlier, but it’s a meaningful extra layer for endpoints that accept images from untrusted users and then display them back to other users.

Multiple Sizes (Thumbnails)

app.post('/upload', upload.single('image'), async (req, res) => {
  try {
    const filename = `${Date.now()}`;
    const buffer = req.file.buffer;
    
    // Original
    await sharp(buffer)
      .jpeg({ quality: 90 })
      .toFile(`uploads/${filename}-original.jpg`);
    
    // Large
    await sharp(buffer)
      .resize(1200, 1200, { fit: 'inside' })
      .jpeg({ quality: 85 })
      .toFile(`uploads/${filename}-large.jpg`);
    
    // Thumbnail
    await sharp(buffer)
      .resize(300, 300, { fit: 'cover' })
      .jpeg({ quality: 80 })
      .toFile(`uploads/${filename}-thumb.jpg`);
    
    res.json({
      original: `${filename}-original.jpg`,
      large: `${filename}-large.jpg`,
      thumbnail: `${filename}-thumb.jpg`,
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

Generating multiple derivatives from one upload is standard practice for anything rendered in a responsive UI - shipping the “original” resolution to a mobile client that only displays a 300px thumbnail wastes bandwidth on both ends. Note that this handler does three sequential awaits against the same buffer; for higher throughput you’d run them with Promise.all since Sharp’s operations are independent of each other, and in a production pipeline you’d typically push each derivative to object storage (and a CDN) rather than local disk, for the same durability reasons covered in the storage engine section.

AWS S3 Upload

npm install multer-s3 @aws-sdk/client-s3
const multer = require('multer');
const multerS3 = require('multer-s3');
const { S3Client } = require('@aws-sdk/client-s3');

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

const upload = multer({
  storage: multerS3({
    s3: s3,
    bucket: 'my-bucket',
    acl: 'public-read',
    metadata: function (req, file, cb) {
      cb(null, { fieldName: file.fieldname });
    },
    key: function (req, file, cb) {
      const filename = `${Date.now()}-${file.originalname}`;
      cb(null, filename);
    }
  })
});

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({
    message: 'File uploaded to S3',
    url: req.file.location,
    key: req.file.key
  });
});

multerS3 is itself a storage engine, which is the important detail: it plugs into the same storage option as diskStorage and memoryStorage, and streams the incoming file directly to S3 as it’s received, without ever writing it to local disk or buffering the whole thing in memory first. That makes it the correct choice for production deployments on ephemeral or read-only filesystems (serverless functions, most container platforms, Cloudflare’s own edge runtime) where diskStorage either doesn’t work or doesn’t persist, and it avoids the “write to disk, then re-upload to S3” pattern some teams reach for by default, which doubles I/O and adds a step that can partially fail and leave an orphaned local file behind.

That orphaned-file failure mode is worth designing around explicitly even when you’re not using S3. If a request is interrupted mid-upload, or fails validation after Multer has already written the file to disk (in a downstream fileFilter check that needs content you only trust once other fields are validated, for instance), diskStorage does not clean up after itself - the partial or rejected file simply stays in uploads/ forever unless your own code removes it. Two patterns handle this reliably:

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    // ... validate, process, save to database ...
    res.json({ ok: true });
  } catch (error) {
    // Clean up the file Multer already wrote, since the request ultimately failed
    if (req.file?.path) {
      await fs.promises.unlink(req.file.path).catch(() => {
        // Log and move on - a failed cleanup shouldn't fail the response
      });
    }
    res.status(500).json({ error: 'Upload processing failed' });
  }
});

For failures that happen outside the request lifecycle entirely (a process crash mid-upload, a client that disconnects before Multer finishes), a periodic cleanup job that deletes anything in the uploads directory older than, say, a few hours and not referenced in your database is cheap insurance against a slowly filling disk.

Progress Tracking

const busboy = require('busboy');

app.post('/upload-with-progress', (req, res) => {
  const bb = busboy({ headers: req.headers });
  
  bb.on('file', (fieldname, file, filename, encoding, mimetype) => {
    let fileSize = 0;
    
    const saveTo = `uploads/${Date.now()}-${filename}`;
    const writeStream = fs.createWriteStream(saveTo);
    
    file.on('data', (data) => {
      fileSize += data.length;
      // Send progress via WebSocket or SSE
      console.log(`Uploaded: ${fileSize} bytes`);
    });
    
    file.pipe(writeStream);
    
    file.on('end', () => {
      console.log(`File ${filename} finished uploading`);
    });
  });
  
  bb.on('finish', () => {
    res.json({ message: 'Upload complete' });
  });
  
  req.pipe(bb);
});

Multer’s public API intentionally doesn’t expose per-chunk progress events, because that would mean exposing busboy’s lower-level streaming API through Multer’s higher-level one, which most consumers don’t need. When you genuinely need live progress (a percentage bar on a large-file uploader, for example), dropping down to busboy directly - as shown above - is the documented escape hatch, since Multer is a thin wrapper around it anyway. Note that this bypasses all of Multer’s convenience features (fileFilter, limits, the req.file/req.files shape), so you’re responsible for re-implementing any validation and size limiting you still need at this lower level.

Security Best Practices

Every one of the pitfalls above compounds if left unaddressed, so treat file upload endpoints as one of the highest-risk surfaces in a typical web application - they accept arbitrary binary data from anonymous or semi-trusted users and, in the worst case, let an attacker write a file with a name and content they chose onto your server’s filesystem. The following practices are meant to be applied together, not as alternatives to each other.

Validate File Type

const fileFilter = (req, file, cb) => {
  // Check MIME type
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
  
  if (!allowedTypes.includes(file.mimetype)) {
    return cb(new Error('Invalid file type'), false);
  }
  
  // Check file extension
  const ext = path.extname(file.originalname).toLowerCase();
  const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
  
  if (!allowedExts.includes(ext)) {
    return cb(new Error('Invalid file extension'), false);
  }
  
  cb(null, true);
};

Checking both the MIME type and the extension is stronger than checking either alone, but as covered in the File Filtering section, both values still come from the client. Treat this as a fast, cheap first filter that rejects obviously wrong uploads early, and pair it with content-based verification (magic-byte sniffing) for anything where the file will later be served to other users or processed by a library that could be exploited by a malformed file.

Limit File Size

const upload = multer({
  dest: 'uploads/',
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
  }
});

Sanitize Filenames

const storage = multer.diskStorage({
  filename: function (req, file, cb) {
    // Remove special characters
    const safeName = file.originalname
      .replace(/[^a-zA-Z0-9.-]/g, '_')
      .toLowerCase();
    
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    cb(null, uniqueSuffix + '-' + safeName);
  }
});

This one deserves a concrete explanation of the attack it prevents: originalname is a value the client controls entirely, and nothing about the multipart format stops it from containing path traversal sequences like ../../etc/cron.d/evil or an absolute path. If you ever build a filesystem path by concatenating destination + file.originalname without sanitizing it first, a crafted filename can make Multer write outside the intended uploads/ directory entirely - onto a config file, a cron directory, or anywhere else the server process has write access. The regex above closes that hole by stripping everything except alphanumerics, dots, and hyphens before the name is ever used to build a path, and prefixing a random suffix additionally prevents filename collisions and guessable URLs for other users’ uploads. Never skip this step under the assumption that “only valid browsers hit this endpoint” - multipart requests are trivial to construct by hand with curl or any HTTP client, so the browser’s own filename sanitization (which is inconsistent across browsers anyway) can’t be relied on as a security boundary.

Store Outside Web Root

// Bad: uploads/ is publicly accessible
const upload = multer({ dest: 'public/uploads/' });

// Good: uploads/ is outside public directory
const upload = multer({ dest: 'private/uploads/' });

// Serve files with authentication
app.get('/files/:filename', authenticate, (req, res) => {
  const filepath = path.join(__dirname, 'private/uploads', req.params.filename);
  res.sendFile(filepath);
});

Even with a sanitized filename, serving uploads straight out of a statically-served directory means anyone with the URL can access the file, and - worse - if an attacker somehow gets a script file past your type checks, a static file server may execute it depending on server configuration (classic examples include .php files under an Apache handler, or .html/.svg files that execute script content when opened directly in a browser). Gating access through an authenticated route, as shown, also means you control exactly who can read a given upload rather than relying on obscurity of the URL.

Scan for Malware

const { exec } = require('child_process');

async function scanFile(filepath) {
  return new Promise((resolve, reject) => {
    exec(`clamscan ${filepath}`, (error, stdout) => {
      if (stdout.includes('OK')) {
        resolve(true);
      } else {
        reject(new Error('Malware detected'));
      }
    });
  });
}

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    await scanFile(req.file.path);
    res.json({ message: 'File is safe' });
  } catch (error) {
    fs.unlinkSync(req.file.path); // Delete infected file
    res.status(400).json({ error: 'Malware detected' });
  }
});

Malware scanning is the layer most teams skip, usually because it adds an external dependency (ClamAV here) and measurable latency to the upload path. It’s most worth the cost on endpoints that accept arbitrary file types from the general public and later distribute those files to other users - a support-ticket attachment system or a public file-sharing feature is a much higher-value target than an avatar uploader that only accepts and re-encodes images. If you do add it, run the scan asynchronously after the initial response where the UX allows (mark the file “pending” until scanned) rather than blocking every upload request on an external process call, since exec()-based scanning can add hundreds of milliseconds or more per file.

Real-World Example: Avatar Upload

const express = require('express');
const multer = require('multer');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;

const app = express();

// Configure storage
const storage = multer.memoryStorage();

// File filter
const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
  
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error('Only JPEG, PNG, and GIF images are allowed'));
  }
};

// Configure multer
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
  }
});

// Upload endpoint
app.post('/api/users/:userId/avatar', 
  authenticate, // Your auth middleware
  upload.single('avatar'),
  async (req, res) => {
    try {
      const userId = req.params.userId;
      
      // Check authorization
      if (req.user.id !== userId) {
        return res.status(403).json({ error: 'Unauthorized' });
      }
      
      if (!req.file) {
        return res.status(400).json({ error: 'No file uploaded' });
      }
      
      // Delete old avatar if exists
      const user = await db.users.findById(userId);
      if (user.avatar) {
        await fs.unlink(`uploads/avatars/${user.avatar}`).catch(() => {});
      }
      
      // Process image
      const filename = `${userId}-${Date.now()}.jpg`;
      
      await sharp(req.file.buffer)
        .resize(400, 400, { fit: 'cover' })
        .jpeg({ quality: 90 })
        .toFile(`uploads/avatars/${filename}`);
      
      // Update database
      await db.users.update(userId, { avatar: filename });
      
      res.json({
        message: 'Avatar uploaded successfully',
        avatar: filename,
        url: `/uploads/avatars/${filename}`
      });
    } catch (error) {
      console.error(error);
      res.status(500).json({ error: 'Upload failed' });
    }
  }
);

// Error handling
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ error: err.message });
  }
  next(err);
});

This example is worth reading end to end because it quietly combines almost every practice covered above: authenticate runs before Multer even parses the body, so an unauthenticated request never gets to consume server resources on a file it will just reject; the authorization check (req.user.id !== userId) happens after the file is parsed but before any processing, so a user can’t overwrite someone else’s avatar even if they guess a valid userId; memoryStorage plus a 5MB limit keeps memory bounded; fileFilter gives a fast first-pass type check; and the old avatar is deleted before the new filename is written, avoiding the same kind of orphaned-file accumulation discussed in the S3/cleanup section. The one gap, consistent with the rest of the guide, is that fileFilter’s MIME check alone won’t catch a deliberately mislabeled file - for a public-facing avatar endpoint you’d likely add the file-type byte-sniffing check shown earlier, given that avatars are displayed back to every other user who views this person’s profile.

sequenceDiagram
    participant Client
    participant Express as Express Route
    participant Auth as authenticate
    participant Multer
    participant Sharp
    participant Disk as uploads/avatars

    Client->>Express: POST /api/users/:userId/avatar
    Express->>Auth: verify session
    Auth-->>Express: req.user set
    Express->>Multer: parse multipart body
    Multer-->>Express: req.file.buffer (memoryStorage)
    Express->>Express: check req.user.id === userId
    Express->>Sharp: resize + re-encode buffer
    Sharp-->>Disk: write optimized JPEG
    Express->>Disk: unlink old avatar (best-effort)
    Express-->>Client: 200 { avatar, url }

Every Multer pitfall is an unset option

Multer’s defaults are deliberately minimal - no size limit, no type restriction, disk storage that never cleans itself up - because it’s a general-purpose parsing library, not an opinionated upload framework. Every one of the pitfalls covered above is really just an unset option. A production-ready upload endpoint sets limits explicitly, verifies file content rather than trusting client-supplied metadata, sanitizes anything that touches a filesystem path, and has an explicit plan for cleaning up files left behind by failed or interrupted requests.

Concretely, that means validating types in fileFilter and checking magic bytes when the stakes are higher, setting fileSize, files, and fields limits, sanitizing filenames against path traversal, keeping uploads outside the web root behind an authenticated route, re-encoding images with Sharp, and cleaning up orphaned files on error paths and with a periodic sweep.


Frequently Asked Questions (FAQ)

Q. Why is req.body missing text fields inside my fileFilter or filename callback?

A. Multer parses the multipart stream in the order the client sends it, and req.body only holds the text fields that have arrived so far. If the client appends the file before the other fields, those fields are not there yet when your fileFilter, destination or filename callback runs for the file. Make the client append text fields before files in its FormData, or move logic that depends on those fields into the route handler, where the whole body has been parsed.

Q. Should I use disk storage or memory storage?

A. Default to diskStorage for anything that might be large or high-traffic, since memory use stays flat regardless of file size. Use memoryStorage only when you need the raw buffer immediately (piping into Sharp, or streaming onward to S3) and you’ve set a conservative fileSize limit to bound worst-case memory use.

Q. Is checking file.mimetype enough to know a file is safe?

A. No - mimetype is copied from a client-supplied header and can be set to anything regardless of the file’s actual content. Use it as a fast first filter, then verify the real file type from its bytes (a library like file-type) before trusting it for anything security-sensitive.