A common requirement across client projects is letting users upload files — profile photos, documents, product images — and storing them somewhere durable rather than on the application server's local disk, which disappears the moment that server is replaced or scaled. It sounds like a solved problem, and mostly it is, but there are enough small decisions along the way that getting it right on the first project saved us from repeating a handful of mistakes on the next several.
Our current default stack
Our current default is `multer` for parsing the multipart form data, combined with `multer-s3` to stream the upload directly to an S3 bucket rather than writing it to local disk first and uploading afterward. This keeps memory and disk usage low even for larger files, and means the application server itself can be treated as fully disposable, since nothing meaningful is ever stored on it — a server can be replaced, scaled, or restarted at any time without any risk of losing an uploaded file that happened to be sitting on its local disk.
Validating uploads before they ever reach S3
A few details worth getting right: validate file type and size before the upload even starts, checking both the file extension and, more importantly, the actual file content signature rather than trusting a client-supplied MIME type, which can be spoofed trivially by anyone with a text editor and basic knowledge of how multipart requests are structured. We check the first few bytes of the file against known magic numbers for the file types we actually accept, and reject anything that does not match, regardless of what extension or declared content type came along with it.
Naming and organizing objects in the bucket
Generate a randomized key rather than trusting the original filename, both to avoid collisions between two different users uploading a file called `photo.jpg` and to avoid exposing anything about the original filename that a user might not have intended to share. We prefix keys with a date-based path segment — year and month — purely for our own operational sanity when browsing the bucket directly, since a flat bucket with hundreds of thousands of randomly-named objects becomes genuinely unpleasant to navigate by hand during debugging, even though the application itself never needs that structure to function.
Bucket policy and CORS
Set the bucket's CORS policy narrowly to the domains that actually need to upload rather than leaving it wide open, which is an easy thing to overlook during initial setup and a real risk to tighten up before launch rather than after. We also scope the IAM credentials the application uses down to exactly the actions it needs on exactly that one bucket — put and get, nothing else, and never full account access — following the same principle of least privilege we would apply to any other credential, since an overly broad S3 credential leaking is a meaningfully worse incident than a narrowly scoped one leaking.
- Presigned URLs for any download that should not be publicly accessible, rather than making the bucket or specific objects public
- A lifecycle policy on any bucket holding temporary or draft uploads, so abandoned uploads do not accumulate storage cost indefinitely
- Server-side encryption enabled by default on the bucket, at essentially no cost and no downside for the vast majority of use cases
Moving image processing off the request path
We also learned to be deliberate about image processing timing: resizing and generating thumbnails synchronously during the upload request adds latency the user directly feels, so we moved that work into a queued background job that runs after the original upload completes, with the UI showing a placeholder until the processed versions are ready. This kept upload response times fast and predictable regardless of how large or how many derivative image sizes a given upload needs, which matters more as a project's image processing needs grow beyond a single thumbnail size. On one project needing five different derivative sizes for various parts of the UI, moving this off the request path was the difference between a two-second upload response and one closer to two hundred milliseconds.
Handling failed and partial uploads gracefully
A detail we missed on our first attempt at this pattern: a network interruption partway through an upload can leave a client thinking an upload failed when S3 actually received a partial object, or leave the application's database with no record of an upload that a user believes succeeded. We now confirm the upload completed successfully via S3's response before writing any record of it to our own database, and we run a periodic cleanup job that removes any orphaned S3 objects with no corresponding database record older than a day, which handles the rare case of an upload that succeeded on S3's side but never got acknowledged back to the application.
Results
None of this is individually complicated, but the accumulated set of small decisions — validation, naming, access scoping, background processing, and cleanup — is what separates a file upload feature that quietly works for years from one that generates an occasional confusing support ticket about a missing or duplicated file. We now start every new project's upload feature from a small internal template that already encodes all of this, rather than rebuilding the same set of decisions from scratch each time.
Testing large file uploads deliberately
Most of our own testing during development happens with small sample files, which is exactly the wrong way to catch problems that only appear at scale. We added a specific test pass using deliberately large files — tens of megabytes rather than the few-hundred-kilobyte images used in everyday testing — and found that our initial timeout configuration on the application server was too aggressive for a slower upstream connection uploading a large file, causing an otherwise-successful upload to be reported as failed to the user even though S3 had received the file completely. Raising the timeout and, more importantly, giving the client-side upload UI an actual progress indicator rather than an indefinite spinner fixed both the real timeout issue and the perceived-slowness complaint that had been layered on top of it.
Considering resumable uploads
For projects expecting larger files on unreliable connections — a client with a mobile-heavy user base uploading video, for instance — we evaluated resumable upload protocols that let an interrupted upload continue from where it left off rather than restarting from zero. We did not adopt this for the project described here, since the file sizes involved were modest and a full restart on failure was an acceptable inconvenience, but for any future project with meaningfully larger files or a user base more likely to be on unreliable connections, this is now the first thing we evaluate rather than defaulting straight to the simpler multer-and-stream approach.
Client-side considerations we now build in by default
- Client-side file type and size validation before the upload even starts, purely as a fast-feedback convenience for the user, understood clearly as a UX nicety rather than a security control, since server-side validation remains the actual gate
- A visible upload progress indicator for anything larger than a couple of megabytes, since users reliably interpret a silent wait as a broken page rather than a working one
- Graceful handling of a user navigating away mid-upload, either by warning them before they leave or by allowing the upload to continue in the background where the framework in use makes that practical
A note on video and larger media specifically
A later project needing video upload support surfaced a wrinkle this pattern alone does not solve: video files often need transcoding into multiple formats and resolutions for broad playback compatibility, which is a meaningfully heavier background job than generating a few image thumbnail sizes. We kept the same core pattern — accept the upload, confirm it landed in S3, then queue background processing — but the processing step itself became a small pipeline of its own, and is a topic worth its own separate write-up once we have more real-world experience running it at scale. We keep this whole pattern, validation rules included, in the same internal starter template mentioned elsewhere on this blog, so a new project's upload feature starts from a known-good baseline rather than a blank file and institutional memory alone.