File Uploads and Downloads

Intermediate
13 min

File Uploads and Downloads

Not every payload is JSON. Profile pictures, invoices, CSV imports and generated reports all travel through your API as binary data. In this lesson you will learn the two ways to accept uploads, how to validate and store files safely with multer, when to let clients upload directly to cloud storage, and how to serve downloads with the right headers.

Two Ways to Upload

| Approach | Request | Best for | |---|---|---| | multipart/form-data | Body with named parts, each with a filename and type | HTML forms, several files plus fields in one request | | Raw binary body | PUT /files/report.pdf with Content-Type: application/pdf | Single files from scripts, direct-to-storage uploads |

Multipart is the browser default. Testing it from the terminal is one flag:

bash
curl -X POST https://api.example.com/users/42/avatar \ -H "Authorization: Bearer $TOKEN" \ -F "avatar=@./photo.png"

Handling Multipart Uploads with multer

express.json() ignores multipart bodies. The multer middleware parses them and hands you the file:

javascript
const multer = require("multer"); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB fileFilter: (req, file, cb) => cb(null, ["image/png", "image/jpeg"].includes(file.mimetype)), }); app.post("/users/:id/avatar", upload.single("avatar"), async (req, res) => { if (!req.file) return res.status(415).json({ error: "Only PNG and JPEG images are accepted" }); const key = `avatars/${req.params.id}/${crypto.randomUUID()}.${req.file.mimetype.split("/")[1]}`; await storage.put(key, req.file.buffer, req.file.mimetype); // S3, GCS, local disk... res.status(201).set("Location", `/files/${key}`).json({ url: `/files/${key}`, size: req.file.size }); }); app.use((err, req, res, next) => { if (err.code === "LIMIT_FILE_SIZE") return res.status(413).json({ error: "File exceeds 5 MB" }); next(err); });

Three safety rules are built in. The file gets a server-generated name; the client's originalname is never used as a path, which prevents directory traversal. The client-supplied mimetype can lie, so also check the magic bytes with a library such as file-type. And oversized uploads fail with 413 Content Too Large before they fill memory. For large files use multer.diskStorage or stream to object storage.

Direct-to-Storage with Presigned URLs

Routing gigabytes through your API server wastes its capacity. The scalable pattern is a presigned URL: the API returns a short-lived URL signed for a specific key, size and content type, and the client PUTs the bytes directly to S3 or a similar service:

  1. POST /uploads with { "filename": "video.mp4", "contentType": "video/mp4", "size": 734003200 }
  2. Response: { "uploadUrl": "https://bucket.example.com/...signature", "key": "videos/...", "expiresIn": 900 }
  3. Client: PUT uploadUrl with the raw body
  4. POST /videos with { "key": "videos/..." } to create the resource

Your server never touches the bytes, yet it still decides who may upload what.

Serving Downloads

For a file on disk, res.download() streams it with Content-Disposition: attachment so the browser saves it. Both res.download() and res.sendFile() honour Range requests with 206 Partial Content, so downloads can resume. For object storage, redirect to a short-lived signed URL rather than proxying the bytes:

javascript
app.get("/reports/:id/pdf", async (req, res) => { const report = await db.reports.findById(req.params.id); if (!report || report.ownerId !== req.auth.sub) return res.status(404).end(); if (report.storage === "local") { return res.download(report.path, `report-${report.id}.pdf`); } const signedUrl = await storage.signedGetUrl(report.key, { expiresIn: 300 }); res.redirect(302, signedUrl); });

Use Content-Disposition: inline when the browser should render the file instead.

Common Mistakes

  • Serving uploads from your API's origin without Content-Disposition; an uploaded HTML file then runs as your site.
  • Forgetting authorization on download endpoints. A guessable file URL is a data leak.
Quick Quiz
Question 1 of 2

Which status code should an API return when an uploaded file exceeds the allowed size?

Key Takeaways

  • Uploads arrive as multipart/form-data (forms, several parts) or as a raw binary body (single file).
  • multer parses multipart requests; enforce limits.fileSize, filter types and answer 413 or 415.
  • Never use the client's filename as a path; generate keys and verify content by magic bytes.
  • Presigned URLs let clients upload large files directly to storage under the API's control.
  • Serve downloads with res.download() or a signed redirect, after checking authorization.

Next lesson: Asynchronous Operations: Long-Running Jobs, Polling and Webhooks — handle work that takes longer than a single request.

File Uploads and Downloads - REST APIs | CodeYourCraft | CodeYourCraft