Direct S3 presigned uploads: avoiding the API backend bottleneck
How to eliminate heavy file upload traffic from application servers by using AWS S3 Presigned URLs and asynchronous serverless event processing.
A common anti-pattern in early-stage web architectures is proxying user file uploads directly through an application backend server (such as a Node.js, Django, or Rails API container).
When thousands of users simultaneously upload multi-megabyte images or gigabyte video files, your application server runs out of worker threads, exhausts RAM buffers, and crashes under out-of-memory (OOM) conditions.
ANTI-PATTERN (Proxying Uploads):
Browser ──[500MB Video Stream]──▶ API Server ──[500MB Upload]──▶ S3 Bucket
(Locks CPU & RAM)
SCALABLE ARCHITECTURE (S3 Presigned URLs):
Browser ──[1. Get Presigned URL (20ms)]──▶ API Server
Browser ──[2. Direct HTTP PUT 500MB]───────────────────────────▶ S3 Bucket
(Zero API Server Load)
1. How Direct S3 Presigned Uploads Work
An S3 Presigned URL gives the client temporary, cryptographic authorization (via AWS SigV4) to perform a specific action (like PUT an object) directly against an S3 bucket without exposing AWS credentials to the frontend.
Step-by-Step Flow:
- Metadata Request: The frontend browser sends a lightweight JSON payload to your backend (
POST /api/upload-urlwith{ filename: "video.mp4", contentType: "video/mp4", size: 104857600 }). - Backend Authentication & URL Signing: The backend authenticates the user session, validates file constraints, and calls the AWS SDK:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const command = new PutObjectCommand({ Bucket: process.env.AWS_S3_BUCKET, Key: `uploads/${userId}/${fileId}-${filename}`, ContentType: contentType, }); const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 }); - Instant Response: The backend returns the
presignedUrlto the browser in under 20 milliseconds. - Direct Browser Upload: The browser executes a standard
fetchorXMLHttpRequestwithPUTdirectly to the Amazon S3 endpoint:await fetch(presignedUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, });
2. Asynchronous Post-Processing with S3 Events
Once the file is securely stored in S3, how does your backend know the upload finished?
Instead of relying on the client to send a follow-up “I’m done” HTTP request (which fails if the user closes their tab), leverage S3 Event Notifications:
- S3 Event Trigger: S3 automatically publishes an
s3:ObjectCreated:Putevent to AWS Lambda, SQS, or EventBridge. - Serverless Worker Execution: An AWS Lambda worker parses the event, validates the file checksum, resizes images, or enqueues video transcoding jobs.
- Database State Mutation: The worker updates the database record from
status = 'pending'tostatus = 'ready'.
┌─────────────┐ ObjectCreated ┌────────────────┐ Update DB ┌────────────┐
│ AWS S3 │ ──────────────────────▶ │ AWS Lambda │ ──────────────────▶ │ PostgreSQL │
│ Bucket │ │ Worker Handler │ │ Database │
└─────────────┘ └────────────────┘ └────────────┘
3. Production Hardening Checklist
- Strict Expiration Windows: Set the presigned URL expiration (
expiresIn) to 3–5 minutes. - Enforce Allowed Content Types: Lock the
ContentTypeheader during signing so malicious clients cannot upload arbitrary.exeor executable scripts under a.pngdisguise. - CORS Configuration: Configure your S3 Bucket CORS policy to allow
PUTrequests only from your verified production web origin domains. - Multipart Uploads for Large Files: For files larger than 100MB, use the S3 Multipart Upload API to allow parallel chunk uploads and resume-on-failure capabilities.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.
Q1.How should a downstream dependency failure be handled to protect upstream service availability?
Comments –
Sign in to share your thoughts
Nothing yet. Be the first.