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.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

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.

Database working-set size, S3-compatible object storage, storing references vs. storing blobs.