Choosing Object Storage Instead of the Database for Application Images
TIL that storing delivery-proof photos as base64 blobs in MongoDB worked fine at low volume and turned into slow queries and ballooning backups once real usage kicked in.
The Problem #
The earliest version of photo-proof-of-delivery stored the uploaded image as a base64 string directly on the order document in MongoDB, because it meant one write, one read, no separate service to configure. It stayed fine for weeks and then queries against the orders collection started noticeably slowing down.
Context #
Every delivery generates one photo, and delivery volume was growing — meaning document sizes across the whole collection were growing with it, not just for the photo field but for every operation that touched those documents.
What I Tried #
Adding an index and trying to project only the non-image fields on list queries, avoiding pulling the base64 blob unless specifically needed.
What Went Wrong #
Projection helped read queries, but writes, backups, and replication all still had to move the full document size regardless of what a given query selected — the underlying documents were simply large, and no query-level optimization changes that.
The Solution #
Moved images to object storage (an S3-compatible bucket) and stored only a reference URL on the order document.
const key = `delivery-proofs/${orderId}.jpg`;
await s3.putObject({ Bucket: "delivery-photos", Key: key, Body: fileBuffer });
await Order.updateOne({ _id: orderId }, { photoUrl: `${CDN_BASE}/${key}` });Why It Works #
MongoDB documents storing large binary blobs inflate collection size, working-set memory pressure, and backup/replication time regardless of query patterns — it's a storage-shape problem, not a query-tuning problem. Object storage is purpose-built for large binary blobs and is decoupled from the database's own performance characteristics entirely; the database just holds a small, cheap-to-query reference.
Lessons Learned #
"It's simpler to just store it in the same place as everything else" is true right up until the size of what you're storing changes the performance profile of everything sharing that database — the simplicity was borrowed from future performance, not free.
What I Would Do Differently #
I'd default to object storage for any user-uploaded binary content from the start, since the migration cost (backfilling existing base64 blobs into the bucket, updating every read path) was entirely avoidable by not making the database-blob choice in the first place.
Related Concepts #
Database working-set size, S3-compatible object storage, storing references vs. storing blobs.
Related content
Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.
Preventing Duplicate Votes in a Polling API Under Concurrent Requests
TIL that a 'check if the user already voted, then write' guard is racy under concurrent requests — the fix was a unique compound index plus an atomic $inc, so MongoDB rejects the duplicate instead of the app trying to catch it first.
A 'Hidden' S3 URL Isn't Access Control — Short-Lived Signed URLs Are
TIL that a private-looking direct download URL for a purchased file is still a public URL forever, and the fix is generating a signed, expiring link on demand after checking ownership server-side.