Short answer: for a gaming SaaS product serving generated reports to authenticated customers, use private object storage with short-lived signed URLs, and keep ownership, report state, and thumbnail metadata in the database; reserve database blobs for small, transaction-bound attachments.
That answer is deliberately narrow. A report download is a different workload from a thumbnail request, even when both are “files.” The report may be hundreds of megabytes, while a dashboard may ask for several resized preview images. Treating those transfers as ordinary relational fields makes the busiest path in the product compete with the database's real job.
Large files fail differently from thumbnails
Measure the path that customers actually use: authorization lookup, time to first byte, sustained throughput, concurrent downloads, and the work left behind when a report is deleted or regenerated. A database row is good at answering which customer owns report rpt_1842, whether its job is complete, and which thumbnail variant exists. It is a poor place to hide a large byte stream from the rest of the system.
The clean boundary is simple. Store the report and its generated thumbnail variants as private objects. Store an object key, content type, byte count, checksum, dimensions, generation status, and owner or tenant ID in the database. After authenticating the customer and checking that row, the application mints a signed URL for the requested object. The browser then downloads the bytes directly.
This is an authorization design, not a naming trick. A key such as tenant-42/report-rpt_1842/preview-320.webp should identify an object, but possession of that string should not grant access. The application must check the principal against the database record before producing a URL, and the URL should expire according to the product's session and download needs. The object store should remain private.
I also check the response semantics instead of trusting a dashboard metric: an HTTP 206 tells me that a range was served, not that the full report was delivered correctly. The download path needs resumability tests, checksum validation where appropriate, and measurements split by original reports and thumbnails. Fast thumbnails can conceal a poor large-file path.
How should a European SaaS app protect private user-uploaded images with signed thumbnail URLs?
The important comparison is the failure boundary. A signed URL can reduce application-server egress and keep authorization close to the record lookup, but it does not make a missing object, stale metadata, or an over-broad cache policy disappear. A database blob keeps bytes and metadata within one transactional system, but every large transfer now shares resources with queries, replication, backups, and maintenance.
Thumbnail generation adds another boundary. Do not overwrite the original when producing 320, 640, and 1280 pixel variants; give each variant a deterministic key and a row describing its source and state. If a worker retries, the operation should be idempotent. If a source report is replaced, the old variants need an explicit retirement path rather than an assumption that a cache will eventually forget them.
Here is the trade-off I would put in a design review:
| Choice | Helps with | Cost or boundary |
|---|---|---|
| Private object storage plus signed URLs | Direct large-file delivery and separate thumbnail reads | Two systems must agree on ownership, state, and cleanup |
| Database blobs | One transaction and one backup authority for small attachments | Large reads compete with relational work and database replication |
| Public object URLs | Simple anonymous caching and sharing | It does not satisfy authenticated, private report delivery |
| Proxying every byte through the app | Centralized policy and response shaping | Application instances become the throughput bottleneck |
There is no universal winner. A small receipt that must commit or roll back with an order can belong in a database. A generated match-analysis report with several preview sizes has a different access pattern and deserves a different boundary.
Reconcile the two ledgers
The first failure mode is an authorization race: a report is revoked, but an already-issued URL remains usable. Short expiries limit that window; they do not provide instantaneous revocation. For highly sensitive reports, the application may need an authorization-aware proxy or an explicit download session, accepting the throughput cost.
The second is orphaning. A completed database transaction can still be followed by a failed object write, or an object write can succeed before the row is committed. Use a state machine such as pending, ready, and deleting, record the intended key before work begins, and run reconciliation that compares database records with bounded object listings. The reconciler should report candidates and apply a retention rule; it should not guess that every unreferenced object is safe to delete immediately.
Consider one concrete lifecycle. A report job creates a row for rpt_1842 with state pending, writes the immutable source object, generates three thumbnails, verifies their checksums, and changes the row to ready only after every required variant is addressable. If the worker is interrupted after the source write but before the last thumbnail, a retry resumes from the recorded state; it does not mint a customer URL for a half-finished report. If the customer deletes the report during generation, the row moves to deleting, new URL issuance stops, and cleanup can remove the source and variants after the configured retention window. This order makes a partial failure visible to operators and predictable to the next retry, which is more useful than pretending two independent stores have a single atomic commit.
The third is a false performance conclusion. A CDN-like cache hit for a 40 KB thumbnail says little about a 700 MB report downloaded by many customers at once. Test both classes with realistic concurrency, range requests, expired URLs, and a slow client. Your mileage may vary by region, egress path, and the customer's network, so publish the test conditions alongside the result.
Europe adds policy constraints that throughput measurements cannot answer. Data residency, retention, deletion obligations, encryption ownership, and disaster-recovery geography must be checked against the selected service and contract. I'm not sure a single storage configuration can satisfy every residency policy; the missing input is the customer's required recovery region and retention period.
A blob column has a narrow job
Use a database blob when the attachment is small, the write must be inseparable from a relational transaction, access volume is low, and the team already has a tested backup and restore process for that data shape. The operational simplicity can outweigh the performance benefit of introducing a second byte store.
The catch is that “small” needs a measured limit owned by the team, not a vague rule in a README. Set a byte threshold, observe query latency and replication lag, and reject or reroute objects above it. Also decide whether database backups, object backups, and generated variants share the same recovery point. A report may be regenerated from source data; a customer-uploaded image may not be.
Keep the database route when transactional coupling is the requirement. Move the bytes out when sustained delivery is the requirement. Mixing those reasons produces a system that is hard to explain and harder to capacity-plan.
Small is a policy.
Migrate with an escape hatch
Start with an inventory of existing rows, objects, variants, and access rules. Add the object key and lifecycle state to the database, then make the new path observable before migrating old data. Log the tenant ID, logical report ID, variant, authorization decision, URL issuance, byte range, completion status, and checksum result without logging the signed URL itself.
Route one report class first. Compare application CPU, database read latency, object-store throughput, failed downloads, and cleanup backlog against the old path. A rollback should switch reads back to the old location for records that have not reached the ready state; it should not require deleting the destination object.
Then migrate thumbnails separately. Their high request count makes cache behavior and key stability important, but their small size makes them a poor proxy for report throughput. Delete only after the database points to the new object, a verification read succeeds, and the retention window for rollback has passed.
The practical rule is modest: keep relational facts relational, keep large bytes on a private delivery path, and make every cross-system transition visible. For an authenticated gaming SaaS product, that usually gives generated reports the throughput they need while leaving customer ownership and policy decisions where they can be queried and audited.
Top comments (0)