DEV Community

Cover image for File Uploads in Express: Multer, Cloudinary, and the Buffer Problem Nobody Warns You About
Chinwuba
Chinwuba

Posted on

File Uploads in Express: Multer, Cloudinary, and the Buffer Problem Nobody Warns You About

Continuing my Express roadmap (auth, CRUD, and databases are behind me — this is Phase 4), I finally sat down to actually understand file uploads instead of copy-pasting Multer boilerplate. Here's what I learned building a small /upload route from scratch.

The problem: HTTP wasn't built for files

express.json() parses request bodies as text. A file is binary data — sending it as JSON would mean base64-encoding it, bloating the size by 33%. Instead, file uploads use multipart/form-data, a format that packages multiple "parts" (text fields, binary files) into one request, each with its own header describing what it is. Express has no built-in parser for this. That's the whole reason Multer exists.

Multer: unpacking the multipart request

Multer is a factory, not middleware itself — you call it with a config object and it returns something with methods like .single():

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

app.post("/upload", upload.single("file"), (req, res) => {
  // req.file now exists
});
Enter fullscreen mode Exit fullscreen mode

I used memoryStorage() instead of diskStorage() deliberately. Render's containers are ephemeral — same reason you can't rely on in-memory arrays for persistent data, you can't rely on the local filesystem either. There's no point writing a file to disk on the server just to immediately re-upload it to Cloudinary. Keeping it as a Buffer in RAM skips that step entirely.

The buffer-to-stream problem

Cloudinary's standard upload() method expects a file path. With memoryStorage, you don't have a path — you have a buffer. Cloudinary handles this case with upload_stream(), but it's callback-based, not promise-based, so it doesn't play nicely with async/await out of the box. I wrapped it manually:

const uploadToCloud = (buffer) => {
  return new Promise((resolve, reject) => {
    const mainUpload = cloudinary.uploader.upload_stream((error, result) => {
      if (error) return reject(error);
      resolve(result);
    });
    streamifier.createReadStream(buffer).pipe(mainUpload);
  });
};
Enter fullscreen mode Exit fullscreen mode

streamifier converts the buffer into a readable stream, which gets piped into the writable stream upload_stream returns. This was the one genuinely new pattern in the whole build — everything else was applying concepts I already had.

Validation isn't optional

Two layers, both configured in the Multer instance itself:

const upload = multer({
  storage: storage,
  limits: { fileSize: 5 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    if (ALLOWED_TYPES.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error(`Invalid file type: ${file.mimetype}`), false);
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

Here's the trap: when fileFilter rejects a file, or the size limit is exceeded, Multer throws before your route handler's try/catch ever runs. The error happens inside the middleware. My first attempt returned raw HTML error pages for both cases because nothing was catching it.

The fix is Express's four-parameter error middleware — the signature itself (err, req, res, next) is what tells Express "this is an error handler," placed after all routes:

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ multererr: err.message });
  } else {
    return res.status(400).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Also worth noting: res.json({ error: err }) doesn't work the way you'd expect. Error objects don't serialize cleanly through JSON.stringify — message and stack are non-enumerable, so you often get back {}. You need err.message explicitly.

Finishing with Prisma

Once the file lands on Cloudinary, the URL alone isn't useful without persistence:

const newAsset = await prisma.asset.create({
  data: {
    name: req.file.originalname,
    size: req.file.size,
    mimetype: req.file.mimetype,
    url: result.secure_url,
  },
});
Enter fullscreen mode Exit fullscreen mode

One bug I hit here: req.file.filename doesn't exist with memoryStorage — that field only gets populated by diskStorage with a custom filename function. The correct property is req.file.originalname. Small thing, but it would've silently written undefined into the database if I hadn't checked.

Top comments (0)