Warming up the neural circuits...
By the end of this chapter you will:
Your Node.js server is a bicycle courier. Don't ask it to deliver a grand piano. Give the sender the warehouse address and let them drive the truck themselves.
You're moving to a new apartment. You have two options: (A) Ask a friend with a bicycle to carry your couch, bed, and 50 boxes one at a time across town. Each trip takes 30 minutes, your friend is exhausted, and you pay them for 25 trips. (B) Rent a moving truck, drive everything in one trip, and your friend helps unpack at the destination.
Option A is proxying file uploads through your Node.js server: the client uploads to your server, your server buffers the entire file in memory, then uploads to S3. A 500MB video upload means 500MB of server RAM consumed, 500MB of ingress bandwidth, 500MB of egress bandwidth, and your blocked during the transfer. Under load, 10 concurrent uploads = 5GB of RAM and your server crashes with an OOM (out of memory) kill.
Option B is pre-signed URLs: your server generates a short-lived URL that gives the client direct access to upload to S3. The client uploads directly to S3 — your server never touches the bytes. You validate the request, generate the signed URL in 5ms, and move on. S3 handles the bandwidth, the resumability, and the durability. This chapter covers multipart parsing, pre-signed URLs, the tus resumable upload protocol, file beyond file extension, image processing pipelines, and the patterns used by YouTube and Instagram to handle uploads at planetary scale.
When a browser uploads a file via <input type="file"> or FormData, it sends a multipart/form-data request. Here's what the raw HTTP body looks like:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="caption"
My vacation photo
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="photo"; filename="beach.jpg"
Content-Type: image/jpeg
(binary JPEG data here — could be megabytes)
------WebKitFormBoundary7MA4YWxkTrZu0gW--Node.js's built-in HTTP parser does NOT parse multipart bodies. You need a like multer:
import express from 'express';
import multer from 'multer';
import path from 'path';
const app = express();
// Store files on disk (not memory!) for anything > 1MB
const upload = multer(
multer.memoryStorage() stores the entire file in a Buffer in RAM. One 500MB upload = 500MB of heap. Three concurrent = 1.5GB, and Node.js crashes with OOM. Always use diskStorage for multer and stream to S3 from disk, or better yet, bypass your server entirely with pre-signed URLs.
Pre-signed URLs are the single most important concept in file uploads. They give a client temporary, scoped access to S3 without sharing your AWS credentials.
Upload flow with pre-signed URLs:
1. Client → Server: POST /api/upload-url { filename: 'beach.jpg', contentType: 'image/jpeg' }
2. Server → S3: Generate pre-signed PUT URL (valid 5 min)
3. Server → Client: { uploadUrl: 'https://s3.../beach.jpg?X-Amz-...', fileKey: 'uploads/abc123/beach.jpg' }
4. Client → S3: PUT uploadUrl with file bytes (server never sees the file)
5. Client → Server: POST /api/upload-complete { fileKey: 'uploads/abc123/beach.jpg' }
6. Server: Validate the file exists in S3, create database recordimport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import crypto from 'crypto';
import express from 'express';
const s3
app.post('/api/upload-complete', async (req, res) => {
const { fileKey } = req.body;
// Verify the file actually exists in S3
try {
await s3.send
Sometimes you can't use pre-signed URLs: the file needs server-side transformation before storage, you're behind a corporate firewall, or you're not using S3. In these cases, stream — never buffer:
import { PassThrough } from 'stream';
import { Upload } from '@aws-sdk/lib-storage';
import multer from 'multer';
import express from 'express';
// Use multer with diskStorage, then stream from disk to S3
app
For large files (videos, datasets, backups) on unreliable connections, you need resumable uploads. The tus protocol (tus.io) is the open standard. It works by splitting the file into chunks and uploading them sequentially, with the ability to resume from where it left off.
import { Server } from '@tus/server';
import { S3Store } from '@tus/s3-store';
import express from 'express';
const app = express();
const tusServer = new Server
The tus protocol flow:
POST /uploads/files with Upload-Length: 524288000 (500MB) and Upload-Metadata: filename bXl2aWRlby5tcDQ= (base64 encoded).Location: /uploads/files/abc123 — a unique upload URL.PATCH /uploads/files/abc123 with Upload-Offset: 0 and the first chunk.Upload-Offset: 5242880 (5MB received).HEAD /uploads/files/abc123 to check offset.Upload-Offset: 209715200 (200MB received).PATCH /uploads/files/abc123 with Upload-Offset: 209715200 and remaining data.Never trust file.mimetype (it comes from the client) or the file extension. Validate at multiple levels:
import { fileTypeFromFile } from 'file-type';
import sharp from 'sharp';
import path from 'path';
interface ValidationResult {
valid: boolean;
reason?: string;
Every file format has a unique byte signature at the start. JPEG files start with FF D8 FF, PNG with 89 50 4E 47, PDF with 25 50 44 46. The file-type package reads the first few bytes of the file — not the filename — and determines the real type. This catches virus.exe renamed to totally_safe.jpg.
Once an image is uploaded, you typically need multiple sizes: thumbnail (150px), medium (600px), full (1920px). Sharp is the standard for this in Node.js:
import sharp from 'sharp';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const IMAGE_SIZES = [
{ name
For user-generated content, virus scanning is non-negotiable. ClamAV is the open-source standard:
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export async function scanFile(filePath: string):
If the virus scanner crashes, times out, or can't read the file, treat it as infected. "Fail closed" means blocking on uncertainty. "Fail open" means one scanner bug and malicious files sail through.
Never serve user-uploaded files directly from S3. Put a CDN (CloudFront, Cloudflare, Fastly) in front:
// When generating URLs for clients, use CDN domain, not S3 domain
const cdnUrl = `https://cdn.yourapp.com/${s3Key}`;
// S3 objects get Cache-Control metadata
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body:
Benefits of CDN fronting:
cdn.yourapp.com, not s3.amazonaws.comYouTube's upload pipeline processes 500+ hours of video uploaded every minute. Their architecture reveals patterns you can downscale:
Chunked upload with resumability. YouTube's upload client splits videos into chunks (typically 1-8MB each). If your connection drops at 40%, the client resumes from chunk N, not byte 0. This is conceptually the tus protocol, though YouTube uses a proprietary implementation.
Processing happens asynchronously. When the upload completes, the video enters a processing pipeline: transcoding to multiple resolutions (144p through 4K), thumbnail generation, content ID matching (copyright detection), and ML-based content moderation. None of this blocks the upload confirmation — the creator sees "Processing... 0%" immediately after upload.
Multiple storage tiers. Hot videos (trending, recent uploads from popular creators) are stored on SSD-backed storage for fast access. Warm videos sit on HDD. Cold videos (years old, low views) go to tape archive. This tiering saves millions in storage costs.
Instagram's image pipeline processes 100M+ photos per day:
Progressive loading. Instagram serves a 200-byte blurhash immediately, followed by a low-res version (50KB), then the full-res version. The user sees something instantly even on 3G.
Client-side preprocessing. Before upload, the Instagram app resizes and compresses the image on-device. A 12MP phone photo (8MB) becomes a 1080px JPEG (200KB) before it ever touches the network. This reduces upload time and server processing load.
CDN multi-tier caching. Profile photos (frequently accessed, small) are cached aggressively at edge. Feed photos (timely, large) have shorter TTLs. Stories (ephemeral, 24h life) use the shortest TTLs and may not be cached at all if they're viewed by few people.
Key lesson from both: the upload is just the first step. The real engineering is in the processing pipeline (transcoding, resizing, moderation) and the delivery architecture (CDN, progressive loading, tiered storage).
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
Buffering entire files in memoryStorage | 500MB upload = 500MB RAM; 3 concurrent = OOM crash | Use diskStorage or pre-signed URLs; stream, never buffer |
Trusting file.mimetype from the client | Client can send virus.exe with Content-Type: image/jpeg | Validate magic bytes with file-type package; check actual file headers |
| Proxying uploads through Node.js to S3 | Server pays for ingress AND egress bandwidth; file touches server unnecessarily | Use S3 pre-signed URLs — client uploads directly to S3 |
| No file size limit | Attacker uploads a 10GB file, fills your storage, runs up your bill | Set limits.fileSize in multer and validate size before generating pre-signed URLs |
| Serving files directly from S3 | High egress costs ($0.09/GB); no caching; slow for global users | Put a CDN in front (CloudFront/Cloudflare); set Cache-Control headers |
| No virus scanning | User-generated content is the #1 malware vector for platforms | Scan with ClamAV (or a cloud ); fail closed if scanner errors |
| Using predictable filenames | user_123_avatar.jpg allows attackers to enumerate and scrape all user uploads | Use for filenames; never expose sequential IDs in object keys |
/tmp/) expire after 24 hours; processed uploads transition to Standard-IA after 30 days, Glacier after 90 days. This saves 40-70% on storage costs.@aws-sdk/lib-storage Upload class handles this automatically. Essential for files > 100MB./api/upload-complete (which can be spoofed), configure S3 to send an SNS/SQS/Lambda event on s3:ObjectCreated. Your backend processes the file when S3 confirms the object exists.@aws-sdk/lib-storage Upload class with queueSize: 10 does this. Tune partSize (5MB-5GB, default 5MB) and queueSize (1-10) per workload.Promise.all across sizes./api/upload-complete with any fileKey they guess, claiming to have uploaded it. Always verify the object exists in S3 (HeadObject) and that the userId in the S3 metadata matches the requesting user.Content-Disposition: attachment; filename="..." for downloadable files. Without it, evil.html uploaded by a user renders in the browser — potentially accessing cookies, localStorage, or performing phishing if served from your domain. Serve user content from a separate domain (e.g., usercontent.yourapp.com) to isolate it from your main app's cookies and context.PutObject for disallowed content types. Defense in depth: even if your app validation is bypassed, S3 rejects the upload./api/upload-url is cheap but can be abused. Rate limit by user ID and IP — a free user shouldn't be able to request 10,000 upload URLs per minute (storage costs add up even if files are tiny).Set up multer with disk storage. Create an Express endpoint that accepts a single image upload via multer. Store to /tmp/uploads. Return the filename, size, and mimetype. Test with curl -F "photo=@beach.jpg".
Generate a pre-signed S3 upload URL. Using @aws-sdk/s3-request-presigner, write an endpoint that accepts filename and contentType and returns a pre-signed PUT URL valid for 5 minutes. Test by using curl to PUT a file directly to the returned URL.
Build an image processing pipeline. After upload, use sharp to generate thumbnail (150×150), medium (600×600), and large (1920×1080) variants. Upload all variants to S3. Store the URLs in a database. Return all URLs to the client.
Implement file validation with magic bytes. Write a validation function that checks: file extension (allowlist), MIME type from magic bytes (file-type package), and image validity (can sharp open it?). Reject files that fail any check. Log rejection reasons for security monitoring.
Build a resumable upload system with tus. Set up a @tus/server with S3 store. Implement a client (browser or Node.js) using tus-js-client that uploads a 500MB file. Simulate a connection failure at 60% and verify the upload resumes correctly.
End-to-end upload security. Implement: (a) pre-signed URL generation with user-scoped keys (uploads/{userId}/{uuid}.ext), (b) S3 bucket policy that denies uploads not matching the user's prefix, (c) virus scanning via ClamAV in a worker, (d) automatic deletion of infected files, (e) rate limiting on upload URL generation (10/min per user). Write tests that verify each security layer blocks unauthorized access.
Q1: What is a pre-signed URL and why use it instead of uploading through your server?
Answer: A pre-signed URL is a time-limited URL generated by the server using its AWS credentials that grants temporary access to upload or download a specific S3 object. Using it means the client uploads directly to S3 — the server never handles the file bytes. Benefits: (1) server RAM/CPU isn't consumed by file transfer, (2) server bandwidth costs are eliminated, (3) uploads benefit from S3's scale and reliability, (4) the server stays fast for API requests while S3 handles multi-GB uploads.
Q2: How do you validate that an uploaded file is actually an image?
Answer: At minimum, three checks: (1) File extension allowlist (.jpg, .png, .webp) — but this is easily spoofed. (2) Magic bytes — read the first few bytes of the file to determine the actual format (JPEG: FF D8 FF, PNG: 89 50 4E 47). Use the file-type package. (3) Parse the file with an image library (sharp) — if it can't decode it, the file is corrupted or not an image. Optionally, (4) check dimensions (reject unreasonably large sizes to prevent decompression bomb attacks).
Q3: What's the difference between diskStorage and memoryStorage in multer?
Answer: diskStorage writes the uploaded file to a temporary directory on disk. The file is accessed via req.file.path. RAM usage is minimal regardless of file size. memoryStorage stores the entire file in a Buffer in req.file.buffer. RAM usage equals file size — a 500MB upload uses 500MB of heap. Never use memoryStorage in production unless you have a hard file size limit under 5MB and trust your users.
Q4: How would you design a file upload system that handles 10,000 concurrent uploads of files up to 5GB each?
Answer: The architecture would be: (1) Pre-signed URLs for direct S3 upload — the API servers never touch file bytes. They validate the request (file type, size, user quota) and generate a pre-signed URL in < 5ms. (2) Multipart upload via S3 — files > 100MB use S3 multipart upload (split into 5MB-5GB parts, upload in parallel). (3) S3 event notifications → SQS → workers — when S3 confirms the object is created, an SQS message triggers the processing pipeline (virus scan, transcoding, thumbnail generation). (4) Separate worker pools — CPU-bound workers (video transcoding) scale independently from I/O-bound workers (metadata extraction). (5) CDN for delivery — processed files are served via CloudFront with appropriate cache policies. (6) Rate limiting and quotas — per-user upload limits prevent abuse; storage quotas trigger cleanup of old files.
Q5: A user uploads an file named innocent.jpg. It's served from cdn.yourapp.com. What's the risk and how do you fix it?
Answer: The risk is stored XSS (Cross-Site Scripting). When the browser loads https://cdn.yourapp.com/uploads/innocent.jpg, the server returns the file with Content-Type: image/jpeg (based on the original claimed MIME type). But if the file is actually HTML with <script> tags, some browsers perform "content sniffing" and execute the HTML even with the wrong Content-Type. The attacker's script now runs on cdn.yourapp.com's origin, accessing cookies and localStorage for that domain. Fixes: (1) Serve user content from a completely separate domain (usercontent.yourapp.com) that has no cookies or auth context — this is the critical fix. (2) Set X-Content-Type-Options: nosniff to prevent MIME sniffing. (3) Validate the actual content type via magic bytes, not just the extension. (4) For downloadable files, force Content-Disposition: attachment so the browser downloads rather than renders.
Q6: Compare the tus resumable upload protocol to S3 multipart upload. When would you choose one over the other?
Answer: S3 multipart upload is S3-specific — it splits a file into parts, uploads parts in parallel, and S3 reassembles. It's ideal when: you're already on AWS, the client can use the AWS SDK, and you want S3-managed reliability. Tus is a protocol (open standard) that works with any storage backend (S3, GCS, local disk, etc.). It's ideal when: you need backend portability, the client is a browser using tus-js-client, or you need upload progress that survives page refreshes (tus stores upload on the server). For a pure AWS shop, S3 multipart with Transfer Acceleration is simpler. For a multi-cloud or browser-heavy application, tus provides a better developer experience and protocol-level resumability without AWS SDK dependencies on the client.