Upload to S3 with a signed URL instead of through your backend
Fifty concurrent 4 MB uploads through a server hold 200 MB of RAM and cross the network twice. How to sign a five-minute S3 upload URL and check the file later.
When I joined a government project building a national ID and passport system, I started by reading the cloud bill. It was eight times larger than it needed to be, for three separate reasons. This post covers the third, because almost everyone gets it wrong and it scales worst.
Every ID or passport application in that system was an upload: photographs of faces, photographs of documents and biometrics captured in the field. Each of those files traveled through the backend on its way to storage.
What routing uploads through the backend costs
The pattern looks harmless. A file arrives at your server, middleware buffers it, your code validates it, and then you push it to object storage:
1app.post('/upload', upload.single('file'), async (req, res) => {2 await s3.putObject({3 Bucket: BUCKET,4 Key: key(req),5 Body: req.file.buffer,6 })7 res.json({ ok: true })8})Three things happen here that a small deployment hides.
Every byte sits in your instance's memory. A 4 MB photograph takes 4 MB of RAM for as long as the request lasts, so fifty concurrent uploads hold 200 MB that does nothing but wait. You end up sizing the instance for peak upload concurrency instead of for the work your application does.
Every byte also crosses the network twice, from the client to your server and from your server to storage. You pay for a transfer that had no reason to exist, and you pay again in latency, because the client's upload isn't finished until your server's upload is.
The throughput ceiling is now set by your instance instead of by your storage. Object storage will absorb far more concurrent writes than any single application server, so putting the server in the middle replaces a service designed for this load with one that wasn't.
None of this raises an error. What you see is a larger instance, slower uploads and a bill you take to be the normal cost of running the system.
Sending the file straight to storage
The client uploads directly to storage, and your server never sees the bytes. The server decides whether the upload may happen and signs a URL that permits that one upload and nothing else, what AWS calls a presigned URL.
1// server: authorize, then sign2app.post('/upload-url', requireAuth, async (req, res) => {3 const key = `applications/${req.user.id}/${randomUUID()}`4 5 const url = await getSignedUrl(6 s3,7 new PutObjectCommand({8 Bucket: BUCKET,9 Key: key,10 ContentType: req.body.contentType,11 }),12 { expiresIn: 300 },13 )14 15 res.json({ url, key })16})1// client: upload straight to storage2const { url, key } = await api.post('/upload-url', {3 contentType: file.type,4})5 6await fetch(url, {7 method: 'PUT',8 body: file,9 headers: { 'Content-Type': file.type },10})11 12await api.post('/applications', { photoKey: key })In this flow the server confirms who is asking, decides where the file may go and sets a short expiry, without handling a single byte of the file.
Where direct uploads go wrong
You lose the chance to inspect the file before it lands, so validate what you can before signing: the content type, the declared size and who is asking. Validate the rest once the object exists. In practice that means a step that reads the stored object and rejects it if it isn't what it claimed to be.
The server also stops knowing when an upload has finished. The client tells you, and clients lie or disappear. Either confirm that the object exists before you treat the record as complete, or subscribe to storage events and let the record complete itself.
A signed URL that was used but never confirmed leaves behind an object that no record points to. A lifecycle rule that expires unreferenced objects in a prefix costs nothing, and it saves you from discovering them a year later.
Five minutes is usually the right expiry: long enough for a slow connection, and short enough that a leaked URL is worthless by the time anyone finds it.
Keep the permission narrow as well. A signed URL is a capability, so sign it for one key, one method and one content type.
What the change did on that project
On that project this was one of three changes, and together they took the monthly bill from $24,000 to $2,000. This one mattered most because of what the product was. Every application in it was an upload, so most of the system's work went through the upload path.
The instances got smaller because they no longer held files, and uploads got faster because each file made one trip instead of two. The throughput ceiling moved from servers we ran to storage that Amazon runs.
When routing through the server is still right
Sometimes it is. If you must transform the file before it's stored, for example to resize or transcode it, strip its metadata or scan it for malware before it exists anywhere, the bytes have to reach something that can do that work.
Even then, a worker that pulls the file from storage afterwards is a better answer than putting your API server in the path of every byte. Check, too, whether you need any of this, or whether the files go through the server only because the tutorial did it that way.
Writing
If you’re working on something similar, send us the details.
The first call takes 30 minutes, and you’re under no obligation.