Skipping the proxy on uploads with presigned S3 URLs
An education platform we work with was collecting video assignments from students. The files ranged from 40MB to 800MB. They were routed through a Node.js server, which read the upload, streamed it to S3, and returned the URL. During exam season, with 200+ concurrent uploads, the server sat at 95% memory and was about to fall over.
The straightforward fix is to stop proxying the file through your application. Generate a presigned S3 URL, hand it to the client, and let the browser PUT directly to S3. Your server processes the request in milliseconds instead of streaming megabytes for minutes.
Generating the presigned URL
The server-side code is short. Using the AWS SDK for Node:
const { S3Client, PutObjectCommand } = require('@aws-sdk/s3-presigned-post');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const s3 = new S3Client({ region: 'ap-south-1' });
async function generateUploadUrl(userId, fileName, contentType) {
const key = `uploads/${userId}/${Date.now()}-${fileName}`;
const command = new PutObjectCommand({
Bucket: 'student-assignments',
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, { expiresIn: 300 });
}The client takes that URL and does a PUT with the file as the body. No file touches your server.
What you give up
The tradeoff is real. When the server no longer sees the file, it cannot validate it. You cannot check the file size until the upload completes. You cannot scan for viruses before the file lands in your bucket. You cannot reject files based on content without downloading them back from S3 after the fact.
We handle this with constraints on the presigned URL and a post-upload step. The `getSignedUrl` call above pins the `ContentType`. To enforce a size limit, use S3's `Content-Length` condition during presigning — the upload fails if the client sends more bytes than the URL allows. For virus scanning, we configure an S3 event notification that triggers a Lambda function to scan the object and delete or quarantine it if needed. The file is briefly available in S3 unscanned, which is acceptable for this client because the scanned files sit in a private bucket with no public read access until an admin approves them.
CORS, because S3 is a different origin
The browser will block the PUT without CORS configured on the bucket. Add a CORS policy that allows PUT methods from your application's origin, with the headers your client sends. This is a one-time setup step:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://app.example.com"],
"MaxAgeSeconds": 3000
}
]Most teams hit this on day one, fix it, and forget it.
When not to do this
If your files are under 1MB and you already have validation logic in your upload handler, the proxy approach is fine. The overhead of generating presigned URLs, handling CORS, and building post-upload scanning is not worth it for small files. We use presigned URLs when files are large, uploads are concurrent, or the application server is the bottleneck. For a form collecting 200KB profile photos, we keep the simple multipart upload through the server.
After we switched the education platform to presigned URLs, their Node server stopped touching file content entirely. Memory usage during exam season dropped from 95% to 12%. The upload completion time stayed roughly the same for students, but the server could handle other requests while uploads were in flight.