August 11, 2026
The File Upload Mistake I Made Twice: Proxying Through My Own Backend
A while back, working as a contractor on a project, one of the first real features I was asked to build was file upload , nothing exotic…
By Akshay M
7 min read
A while back, working as a contractor on a project, one of the first real features I was asked to build was file upload , nothing exotic, just "let users upload a file and store it somewhere durable." The kind of task that looks like a two-hour job on the ticket board.
I didn't think twice about the shape of it. I reached for the pattern I'd used before: client sends the file to my backend, backend forwards it to storage. It shipped, it worked in the demo, everyone moved on. It took two separate incidents, on two separate projects, for me to realize that "shape I'd used before" was the actual bug.
Most of us have written a file upload feature. And many of us have made the same quiet mistake: we put our own server in the middle as a proxy between the user and object storage.
It feels safer. It feels easier. It works perfectly on local. Then one day, in a real environment, things start breaking in strange ways — 405, 413, CORS errors, or worse, Out-of-Memory crashes.
This is the story of how I made that mistake twice, why it stayed hidden the first time, and what finally forced me to see it clearly the second time.
Why People Use the Proxy Approach
Almost everyone starts with the proxy pattern for one or more of these reasons:
- Avoid CORS issues (the browser talks only to our backend).
- Feel more secure because the file "passes through our server".
- Easier to add virus scanning, logging, or business validation in one place.
Like many developers, I initially chose the proxy approach because it felt simpler and safer.
So the flow becomes:
User → Our Backend → Object Storage (OSS / R2 / S3)User → Our Backend → Object Storage (OSS / R2 / S3)On a developer laptop this almost always works. Memory is plentiful, there is no real concurrency, and there are no strict reverse proxies or load balancers in the way.
The First Time: OOM in My Previous Organization
In a previous role I worked on a feature that had multiple sections (inclusion/exclusion style). Each section contained several tabs, and each tab allowed multiple file uploads. In the worst case a user could trigger around 50 file uploads.
The upload path itself looked innocent. The backend exposed a single endpoint that accepted the raw file bytes and forwarded them to Alibaba Cloud OSS. Stripped down to the part that matters (auth, credentials, and OIDC setup abstracted away), it was essentially this:
// endpoint.js — the upload route, implementation is skip for the sake of brevity
// Read the raw request body into memory, up to a size cap.
const rawUpload = express.raw({ type: '*/*', limit: '50mb' })
app.post('/api/oss/upload', rawUpload, async (req, res) => {
const buffer = req.body // <-- the ENTIRE file now lives in memory
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
return res.status(400).json({ error: 'Request body must contain file bytes' })
}
const key = `${prefix}/${fileId}`
const contentType = req.get('content-type')
// Our server now pushes those same bytes up to OSS on a second connection.
const result = await uploadBuffer(process.env.OSS_BUCKET, key, buffer, contentType)
res.status(200).json({ key: result.key, url: result.url })
})
// oss.js — the upload helper, implementation is skip for the sake of brevity
export const uploadBuffer = async (bucket, key, buffer, contentType) => {
const client = await createOssClient() // static keys locally, OIDC role in prod
const result = await client.put(key, buffer, {
bucket,
headers: contentType ? { 'Content-Type': contentType } : undefined,
})
return { key, size: buffer.length, etag: result.etag, url: result.url }
}// endpoint.js — the upload route, implementation is skip for the sake of brevity
// Read the raw request body into memory, up to a size cap.
const rawUpload = express.raw({ type: '*/*', limit: '50mb' })
app.post('/api/oss/upload', rawUpload, async (req, res) => {
const buffer = req.body // <-- the ENTIRE file now lives in memory
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
return res.status(400).json({ error: 'Request body must contain file bytes' })
}
const key = `${prefix}/${fileId}`
const contentType = req.get('content-type')
// Our server now pushes those same bytes up to OSS on a second connection.
const result = await uploadBuffer(process.env.OSS_BUCKET, key, buffer, contentType)
res.status(200).json({ key: result.key, url: result.url })
})
// oss.js — the upload helper, implementation is skip for the sake of brevity
export const uploadBuffer = async (bucket, key, buffer, contentType) => {
const client = await createOssClient() // static keys locally, OIDC role in prod
const result = await client.put(key, buffer, {
bucket,
headers: contentType ? { 'Content-Type': contentType } : undefined,
})
return { key, size: buffer.length, etag: result.etag, url: result.url }
}Notice the shape here. The file travels:
User → our backend (fully buffered in req.body) → OSSUser → our backend (fully buffered in req.body) → OSSexpress.raw({ limit: '50mb' }) buffers the whole request body into memory before our handler even runs, and only then do we hand that buffer to OSS. Every in-flight upload is a full copy of the file sitting in the service's heap.
When we enabled parallel uploads, everything worked fine on local machines. But in the integration environment, uploading maximum-size files in parallel started causing Out-of-Memory (OOM) kills on the service.
The temporary fix we applied was to restrict concurrent upload size (only allow ~100 MB total in flight at any time). It reduced the crashes, but it was only a band-aid — the 50mb per-request limit you see above is exactly that kind of guardrail, not a real solution. We never questioned the fundamental design of sending every file through our application servers.
Because the environment already had proper CORS and CSRF middleware, and because normal testing rarely hit the extreme parallel case, the deeper problem stayed hidden for a long time.
Why the Proxy Approach Fails in Integration / Production
In real environments the proxy pattern creates two serious problems:
1. Infrastructure Limits (405 / 413) Load balancers, API gateways, reverse proxies, and WAFs almost always have body-size limits and method restrictions. Large files trigger 413 Payload Too Large. Some methods get blocked with 405 Method Not Allowed. These limits are rarely present (or are much higher) on a developer laptop.
2. Memory Pressure → OOM Every file now lives in your application servers for some time (either fully buffered — as with express.raw above — or as multiple concurrent streams). When many users upload large files at the same time, memory usage spikes quickly. Integration and production environments usually have tighter resource limits and higher concurrency than local machines, so OOM becomes very real.
This is exactly why the same code that works perfectly on local can become unstable the moment it reaches a shared environment.
The Second Time: A Proxy in Disguise
On my next project I moved to presigned URLs from the start — so I thought I had finally learned the lesson. I hadn't. I had just moved the proxy somewhere less obvious.
Even though the upload URL was presigned, the browser still wasn't talking to the bucket. During local development I was rewriting the signed URL to go through a Vite dev proxy (/r2-api) to dodge CORS:
// Generate the signed URL using the real Cloudflare R2 endpoint
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600,
signableHeaders: new Set(['host'])
});
// Replace the real endpoint with our local Vite proxy to bypass CORS in the browser
const proxiedUrl = signedUrl.replace(
'https://[ACCOUNT_ID].r2.cloudflarestorage.com', // R2_ENDPOINT
'/r2-api'
);
const fileBuffer = await file.arrayBuffer();
// Perform the actual upload via proxy
const uploadResponse = await fetch(proxiedUrl, {
method: 'PUT',
body: new Uint8Array(fileBuffer),
headers: {
'Content-Type': file.type
}
});// Generate the signed URL using the real Cloudflare R2 endpoint
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600,
signableHeaders: new Set(['host'])
});
// Replace the real endpoint with our local Vite proxy to bypass CORS in the browser
const proxiedUrl = signedUrl.replace(
'https://[ACCOUNT_ID].r2.cloudflarestorage.com', // R2_ENDPOINT
'/r2-api'
);
const fileBuffer = await file.arrayBuffer();
// Perform the actual upload via proxy
const uploadResponse = await fetch(proxiedUrl, {
method: 'PUT',
body: new Uint8Array(fileBuffer),
headers: {
'Content-Type': file.type
}
});It was the exact same instinct as before — put something in the middle so the browser never has to talk to storage directly — just wearing a nicer outfit. On localhost it worked flawlessly, so I never questioned it.
How the CORS pain forced me to dig
The illusion broke the moment it left my machine. In the deployed environment there was no Vite dev server rewriting anything, so the browser tried to PUT straight to R2 — and the bucket had no CORS rules whitelisted at the bucket level. On top of that, the app was served behind nginx, so between the reverse proxy and the browser's preflight expectations, every direct upload came back as a CORS (and CSRF-flavoured) failure. Locally: green. Deployed: a wall of blocked cross-origin requests.
That failure is what actually forced me to understand the flow instead of pattern-matching my way around it. Chasing why those preflights were failing made the whole thing obvious: the Vite proxy had been hiding the real problem all along. The browser was never allowed to talk to the bucket because I had never configured the bucket to allow it — I had just been tunneling around that fact in development. The fix was never "add another proxy." The fix was to configure CORS on the bucket and let the browser go direct.
The Right Way: Direct Upload with Presigned URLs
Once I stopped tunneling around the problem, the correct pattern was simple: let the client upload directly to object storage, and configure the bucket to allow it.
This is the part I'd been avoiding by tunneling through a proxy in the first place — actually whitelisting the frontend's origin at the bucket level:
[
{
"AllowedOrigins": [
"https://yourdomain.com",
"http://localhost:5173"
],
"AllowedMethods": [
"PUT",
"GET",
"POST",
"HEAD",
"DELETE"
],
"AllowedHeaders": [
"*"
],
"ExposeHeaders": [],
"MaxAgeSeconds": 3600
}
][
{
"AllowedOrigins": [
"https://yourdomain.com",
"http://localhost:5173"
],
"AllowedMethods": [
"PUT",
"GET",
"POST",
"HEAD",
"DELETE"
],
"AllowedHeaders": [
"*"
],
"ExposeHeaders": [],
"MaxAgeSeconds": 3600
}
]Once this was set on the bucket itself, the browser was allowed to send a PUT straight to the signed URL from both localhost:5173 (local dev) and the real deployed domain — no dev-server rewrite required, no nginx involved, no CSRF-flavoured rejection. The CORS preflight now succeeds because the bucket, not some proxy in front of it, is the one answering it.
Flow:
- Frontend asks the backend for a short-lived upload URL.
- Backend checks authentication + authorization and generates a presigned URL.
- Frontend uploads the file directly to object storage using that URL.
- Frontend tells the backend that the upload finished (or you listen to storage events).
The backend signs a URL but never touches the file body. The bytes go straight from the browser to the bucket.
With the bucket's CORS rules configured properly, the only change on the client was to delete the /r2-api rewrite and hit the real endpoint:
// Generate the signed URL using the real Cloudflare R2 endpoint
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600,
signableHeaders: new Set(['host'])
});
const fileBuffer = await file.arrayBuffer();
// Perform the actual upload directly to Cloudflare R2
const uploadResponse = await fetch(signedUrl, {
method: 'PUT',
body: new Uint8Array(fileBuffer),
headers: {
'Content-Type': file.type
}
});// Generate the signed URL using the real Cloudflare R2 endpoint
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600,
signableHeaders: new Set(['host'])
});
const fileBuffer = await file.arrayBuffer();
// Perform the actual upload directly to Cloudflare R2
const uploadResponse = await fetch(signedUrl, {
method: 'PUT',
body: new Uint8Array(fileBuffer),
headers: {
'Content-Type': file.type
}
});That one deletion is the whole point. The proxy was never load-bearing — it was a workaround for a CORS rule I hadn't written yet.
Why This Approach Is Better
- Your application servers never see the file body → almost zero risk of OOM from uploads.
- No more 413 or 405 errors caused by intermediate proxies.
- Much lower bandwidth and memory cost on your infrastructure.
- Object storage is built exactly for this workload.
- You still stay in full control (auth, validation, key naming, expiry) at the moment you sign the URL.
Final Thoughts
The proxy approach is not "wrong" in a small internal tool with tiny files. But the moment you have real users, larger files, or parallel uploads, it becomes a liability.
The first time, the mistake was loud: buffering entire files in memory and forwarding them, until the service ran out of memory under parallel load. The second time it was quiet — presigned URLs done "correctly," but still routed through a dev proxy to avoid a CORS rule I never wrote. Both came from the same instinct: keep the file flowing through something I control. And in both cases, the thing that actually taught me was the environment breaking — OOM the first time, CORS behind nginx the second.
That pressure is useful. It forces you to question the design instead of just asking for higher limits or adding one more proxy.
I made this mistake twice before the pattern became obvious. If you are currently proxying file uploads through your backend — even behind a presigned URL — it is worth asking whether that is still the right architecture.
Direct uploads with short-lived presigned URLs are simpler, more scalable, and far more honest about where the heavy lifting should happen.
I work as a software contractor, building mobile and backend applications. If you're dealing with something similar — or just want to talk architecture — feel free to reach out. If you require any apps, or any bug fix or things to be designed, or want me to write a blog post, feel free to reach out. Linkedin