Engineering Notes
Home/Backend/Frameworks/File Uploads
Backend · Frameworks

File Uploads

Uploads are hostile, large and slow. Compare proxying files through your API with presigned direct-to-storage uploads, choose memory, disk or streaming, and validate files as if every one is an attack.

⚙️ Backend● Intermediate⏱ 5 min read▶ 1 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • How browsers send files (multipart/form-data)
  • Memory vs disk vs streaming
  • Presigned uploads directly to object storage
  • Validating files and storing references

📚 Before you start

1How a Browser Sends a File

An HTML form with enctype="multipart/form-data" (or fetch with a FormData body) sends a multipart request: one body, split into parts by a random boundary string, each part with its own headers.

http
POST /avatars HTTP/1.1
Content-Type: multipart/form-data; boundary=----X9f2
Content-Length: 48211

------X9f2
Content-Disposition: form-data; name="userId"

42
------X9f2
Content-Disposition: form-data; name="file"; filename="me.png"
Content-Type: image/png

<binary bytes...>
------X9f2--

A regular JSON body parser can't read this — you need a multipart parser (multer / busboy in Express, FileInterceptor in NestJS, request.formData() in Next.js route handlers).

2Memory vs Disk vs Stream

ts
import multer from "multer";

// in-memory: req.file.buffer — simple, but every concurrent upload sits in RAM
const memory = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });

// on disk: streamed to a temp file — RAM stays flat
const disk = multer({ dest: "/tmp/uploads", limits: { fileSize: 50 * 1024 * 1024, files: 1 } });

app.post("/avatars", requireAuth, memory.single("file"), async (req, res) => {
  // req.file: { originalname, mimetype, size, buffer }
});
StrategyProsConsUse for
memory buffersimplest; easy to processRAM × concurrent uploadssmall files (avatars) with a tight limit
temp file on diskflat memorydisk cleanup; single-instance local diskmedium files, post-processing
stream straight to object storageflat memory, no local diskmore complex error handlinglarge files
presigned URL (client → S3 directly)your server never touches the bytestwo-step flowlarge/user-generated media at scale

3Presigned Uploads

For large files the best upload is the one that never passes through your API:

text
1. client → API:  POST /uploads { filename, contentType, size }
2. API validates (auth, type, size), creates a DB record "pending",
   returns a short-lived presigned PUT URL for key uploads/<uuid>
3. client → S3:   PUT <presigned URL> with the bytes
4. S3 → API (event) or client → API: "upload complete" → verify object, mark "ready"

The presigned URL embeds a signature over the bucket, key, method, content type and expiry — S3 rejects anything that doesn't match. See devops-cloud/aws/04-s3.

4Validate Like the File Is Hostile

Every field of an upload is attacker-controlled: filename, the part's Content-Type, and the bytes.

CheckHowWhy
sizeparser limits.fileSize, and reject earlymemory/disk exhaustion
countlimits.files, limits.fieldsresource exhaustion
real typeinspect magic bytes (89 50 4E 47 = PNG), not the extension or declared MIMEevil.php renamed cat.png
filenamenever use it as a path; generate uuid keyspath traversal (../../etc/passwd), overwrites
contentre-encode images (sharp), scan with antivirus for documentspolyglot files, embedded scripts, malware
servingseparate domain/bucket, Content-Disposition: attachment, X-Content-Type-Options: nosniffstored XSS from uploaded HTML/SVG
ts
const SIGNATURES: Record<string, number[]> = {
  "image/png": [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
  "image/jpeg": [0xff, 0xd8, 0xff],
  "application/pdf": [0x25, 0x50, 0x44, 0x46], // %PDF
};
function sniff(buf: Buffer): string | undefined {
  return Object.entries(SIGNATURES).find(([, sig]) => sig.every((b, i) => buf[i] === b))?.[0];
}

5Storing and Referencing Files

Store the bytes in object storage (S3, GCS, Azure Blob) — not in the database, and not on an app server's local disk (which disappears on redeploy and isn't shared between instances). Store metadata in the database:

sql
CREATE TABLE files (
  id UUID PRIMARY KEY,
  owner_id UUID NOT NULL REFERENCES users(id),
  storage_key TEXT NOT NULL,        -- uploads/2024/06/3f1c...png
  original_name TEXT NOT NULL,      -- for display only
  content_type TEXT NOT NULL,       -- the SNIFFED type
  size_bytes BIGINT NOT NULL,
  sha256 TEXT NOT NULL,             -- dedup / integrity
  status TEXT NOT NULL DEFAULT 'pending',  -- pending → ready | rejected
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Heavy post-processing (thumbnails, video transcoding, virus scanning) belongs in a background job (16-background_jobs/), not in the request.

Under the HoodStreaming Multipart Parsing▾

A multipart parser like busboy never needs the whole body in memory. It reads the request stream chunk by chunk and runs a state machine that searches for the boundary sequence (\r\n--<boundary>) — using a Boyer–Moore–Horspool style search so it can scan large chunks quickly and handle a boundary split across two chunks. Between boundaries it parses the part headers, then emits a readable file stream for that part. You pipe that stream wherever it should go (disk, S3 multipart upload, a hash function).

Backpressure makes this safe: if the destination is slower than the client, the destination's write() returns false, the pipe pauses the file stream, busboy stops reading the request socket, TCP's receive window fills, and the client's upload slows down — instead of your process buffering gigabytes. Size limits are enforced as bytes flow: when fileSize is exceeded, the parser truncates the stream and flags it, so you can abort without ever having read the whole file.

A multipart request is parsed as a stream; each file part is size-limited, sniffed by magic bytes, hashed and piped to object storage under a generated key while metadata goes to the database
A multipart request is parsed as a stream; each file part is size-limited, sniffed by magic bytes, hashed and piped to object storage under a generated key while metadata goes to the database

⚠️ Common Pitfalls

  1. No size limit — one request can exhaust memory or disk.
  2. Trusting file.mimetype or the extension — both are client-supplied.
  3. Using originalname in the storage path — path traversal and collisions.
  4. Saving to local disk on a multi-instance deployment — files "disappear" depending on which instance serves the request.
  5. Serving uploads from your main domain with an inline content type — uploaded HTML/SVG executes as your site (stored XSS).
  6. Processing synchronously in the request — timeouts on large files; move to a job.
  7. Forgetting cleanup — temp files and abandoned "pending" uploads accumulate.

📋 Quick Reference

NeedUse
parse multipart (Express)multer / busboy
parse multipart (Nest)@UseInterceptors(FileInterceptor("file")) + @UploadedFile()
small filesmemory storage + strict fileSize
big filesstream to storage or presigned URL
type checkmagic bytes
storage keygenerated UUID
serve safelyseparate origin, nosniff, attachment

🧠 Key takeaways

  • Browsers send files as multipart/form-data; parse with streaming parsers (busboy/multer) and size limits.
  • Prefer streaming over memory buffering; for big files use presigned direct uploads to object storage.
  • Treat files as hostile: check size, magic bytes (not just extension/Content-Type), scan, and re-encode images.
  • Store files in object storage with generated keys; store metadata + key in the DB; serve via signed URLs/CDN.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How do you handle large file uploads?core▾

Upload directly from the client to object storage with pre-signed URLs (multipart for large files), so files bypass your servers. The backend issues the URL, then records metadata after upload completes (via callback or storage event).

Follow-ups interviewers ask
  • Why avoid proxying through the app? It ties up memory, threads and bandwidth.
Q2How do you secure file uploads?core▾

Enforce size limits, validate type by content (magic bytes) not just extension, rename files (never trust user filenames), store outside the web root or in private buckets, scan for malware, and serve user content from a separate domain with correct Content-Type and Content-Disposition.

Follow-ups interviewers ask
  • Why a separate domain? To prevent uploaded HTML/SVG from running scripts in your origin.
Q3How do you process uploaded files?senior▾

Asynchronously: storage event → queue → workers that generate thumbnails, transcode, extract text, or scan; update status in the DB and notify the client.

Follow-ups interviewers ask
  • Why async? Processing is slow and can fail independently.
Q4Streaming vs buffering uploads on the server?core▾

Stream uploads to storage (busboy in Node, streaming in ASP.NET Core) instead of buffering whole files in memory, which would exhaust memory under concurrent uploads.

Follow-ups interviewers ask
  • Multer memoryStorage risk? Large files fill RAM.
Q5How do you support resumable uploads?core▾

Multipart upload APIs (S3 multipart) or the tus protocol: the file is split into parts uploaded independently, retried individually, and completed with a final call.

Follow-ups interviewers ask
  • What cleans up abandoned parts? Bucket lifecycle rules for incomplete multipart uploads.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Why prefer presigned uploads for large files?
Offload bandwidth.
2. Is checking the file extension enough?
Never trust the client.
3. Storing user-chosen filenames as storage keys risks…
Generate keys.
4. multer memoryStorage with 2 GB uploads…
Stream or presign.