How to Handle File Uploads at Scale – React + Node.js + AWS S3 (Pre-Signed URLs)
February 21, 2026
Uploading files is common but tricky at scale. The naive approach (uploading through your server) quickly becomes expensive and slow.
This guide shows you how to use pre-signed URLs — the same technique Instagram and Dropbox use — to let users upload directly to AWS S3 while keeping your server lightweight and secure.
The Problem
Traditional uploads: Client → Server → S3
- →Your server pays for bandwidth (expensive!)
- →Server CPU/memory is consumed by large files
- →Single point of failure — server down = no uploads
- →Slower uploads (extra network hop)
The Solution
Pre-signed URLs: Client → S3 (directly)
- →Client asks your server for upload permission
- →Server generates a temporary URL for S3
- →Client uploads directly to S3
- →Done!
Benefits:
- →✅ 60-80% cost savings — no server bandwidth
- →✅ Faster uploads — direct to S3
- →✅ Infinite scale — S3 handles the load
- →✅ Secure — URLs expire, scoped permissions
Quick Implementation
Backend (Node.js)
import AWS from 'aws-sdk';
import { v4 as uuidv4 } from 'uuid';
const s3 = new AWS.S3({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
});
// Generate upload URL
app.post('/api/upload-url', async (req, res) => {
const { fileName, fileType } = req.body;
const key = `uploads/${uuidv4()}-${fileName}`;
const uploadUrl = await s3.getSignedUrlPromise('putObject', {
Bucket: process.env.AWS_S3_BUCKET,
Key: key,
Expires: 60, // URL expires in 60 seconds
ContentType: fileType,
});
res.json({
uploadUrl,
publicUrl: `https://${process.env.AWS_S3_BUCKET}.s3.amazonaws.com/${key}`,
});
});Frontend (React)
import { useState } from 'react';
export function FileUpload() {
const [progress, setProgress] = useState(0);
const uploadFile = async (file: File) => {
// 1. Get upload URL from server
const { uploadUrl, publicUrl } = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileType: file.type,
}),
}).then(r => r.json());
// 2. Upload directly to S3 with progress
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
setProgress((e.loaded / e.total) * 100);
});
xhr.open('PUT', uploadUrl, true);
xhr.setRequestHeader('Content-Type', file.type);
await new Promise((resolve, reject) => {
xhr.onload = () => xhr.status === 200 ? resolve(publicUrl) : reject();
xhr.onerror = reject;
xhr.send(file);
});
return publicUrl; // File is now live!
};
return (
<input
type="file"
onChange={(e) => e.target.files?.[0] && uploadFile(e.target.files[0])}
/>
);
}Key Points
Server does:
- →Generate temporary, scoped URLs
- →Validate file types and sizes
- →Authenticate users
Client does:
- →Upload directly to S3
- →Handle progress/retries
- →Show UI feedback
S3 does:
- →Store the files
- →Serve them via CDN
- →Handle massive scale
Security Checklist
- →[ ] Validate file types on server
- →[ ] Limit file sizes (e.g., 100MB max)
- →[ ] Use short expiry times (30-60 seconds)
- →[ ] Add rate limiting per user
- →[ ] Store files in private buckets if needed
Common Issues
CORS errors? Configure your S3 bucket:
{
"CORSRules": [{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://yourdomain.com"]
}]
}Uploads failing? Check:
- →Content-Type header matches what was requested
- →URL hasn't expired
- →File size within limits
Cost Comparison
| Approach | Server Cost | Upload Speed | Scale | |----------|-------------|--------------|-------| | Through Server | High | Slower | Limited | | Pre-signed URLs | Low | Faster | Unlimited |
Typical savings: 60-80% on bandwidth costs.
That's It!
Three simple steps to production-ready file uploads:
- →Server generates URL
- →Client uploads to S3
- →Get the public URL
This pattern scales from 1 user to 1 million without changing your architecture.
Happy coding! 🚀