Uploads and static files

Accept a file without accepting a problem, and serve static assets with the right headers.

15of 18
≈ 10Min read
6Parts

What an upload actually looks like

Each part has headers, a blank line, then its content, and the parts are separated by the boundary from the Content-Type header. A JSON body parser cannot read this, which is why uploads always need their own middleware.

multipart/form-data, on the wire
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----X99

------X99
Content-Disposition: form-data; name="title"

Front door
------X99
Content-Disposition: form-data; name="photo"; filename="door.png"
Content-Type: image/png

<the bytes>
------X99--

In practice: multer or busboy

never write the parser in production
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },   // always
  fileFilter(req, file, cb) {
    cb(null, ["image/png", "image/jpeg"].includes(file.mimetype));
  },
});

app.post("/doors/:id/photo", upload.single("photo"), (req, res) => {
  req.file;    // { originalname, mimetype, size, path }
  req.body;    // the text fields
  res.status(201).json({ ok: true });
});

The rules that keep it safe

  • A size limit, always. Without one, a single request can fill the disk or the memory.
  • Never trust originalname. "../../etc/cron.d/evil" is a valid filename as far as a client is concerned. Generate your own name and keep the original only as a label.
  • Never trust the content type. It is whatever the client typed. Check the magic bytes of the file when it matters.
  • Never serve an upload from a path that can execute. Store outside the web root, or on object storage, and serve through a handler.
  • Strip metadata from images if the file will be public: EXIF carries GPS coordinates.
The classic chain

Accept photo.php, keep the name, store it in a folder the server executes, serve it back — and the upload form has become a shell. Every step of that chain is one of the rules above.

Direct to storage

For anything beyond a small avatar this is the normal architecture: presigned URLs, a short expiry, and limits encoded in the signature.

the server never touches the bytes
// 1. the browser asks your API for permission
const { url, fields } = await api.post("/uploads/sign", { type: "image/png", size });

// 2. the browser sends the file straight to S3/R2 with that signed URL
// 3. it tells your API the key, and you record it

// your server stays small, fast and out of the data path

Serving static files

  • Fingerprinted asset (app.7f3a9c.js): cache for a year, immutable.
  • HTML: no-cache — check with the server every time, but a 304 is cheap.
  • Anything user-specific: private, no-store.
  • In front of a real deployment, a CDN does this better than your Node process ever will.
cache what is fingerprinted, revalidate what is not
app.use(express.static("public", {
  maxAge: "1y",
  immutable: true,          // for /assets/app.7f3a9c.js — the name changes on change
  etag: true,
}));

// index.html must NOT be cached for a year, or nobody ever gets the new build
res.setHeader("Cache-Control", "no-cache");   // revalidate every time

And the boring one

Put a reverse proxy (nginx, Caddy, a platform router) in front. It serves static files faster, terminates TLS, enforces body limits before your process sees them, and does not block your event loop.

Practice