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
});
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);
});
};
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);
}
},
});
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 });
}
});
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,
},
});
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)