For a Node.js SaaS deciding how to store generated images and thumbnails, the cheapest useful design is not the lowest byte rate. It is private object storage with a catalog that prevents duplicate variants, orphaned objects, and unauthorized delivery; those are the costs that turn a small image feature into an operational liability.
Short answer: keep generated originals and thumbnail variants in a private object-storage bucket, record ownership and retention in the database, issue short-lived signed URLs only after an authorization check, and use lifecycle cleanup only for objects whose deletion window can tolerate its delay. For a B2B SaaS image-resizing flow, that arrangement is usually easier to audit and operate than serving files from a Node.js process.
The invariant is simple: a URL grants temporary access to a named object, while the database decides whether that object should still be available. Expiring a URL does not delete bytes. A lifecycle rule does not decide whether a tenant may read them. Mixing those responsibilities is how a private preview survives after its record is gone.
The bucket is not your ledger.
What does a Node.js SaaS test before storing generated images and thumbnails in object storage?
Start with an asset record before starting the image job. It should contain the tenant identifier, generation or upload identifier, original key, variant keys, current state, intended expiry, and deletion state. The bucket is for bytes; it is not the application catalog. Object listing can help reconcile storage, but a prefix is not a substitute for an ownership index.
Use immutable keys for each output. An original might be assets/tenant-17/job-184/original.webp; a thumbnail can be assets/tenant-17/job-184/width-320.webp. If a resize worker retries, it should reuse the job's known key or create a new version whose database reference is changed deliberately. A shared name such as latest.webp turns a routine retry into a race between workers.
The request path should look like this:
- Authenticate the caller and load the asset record for that tenant.
- Reject records that are pending, expired, deleted, or owned by another tenant.
- Ask storage for a signed GET URL for the selected variant.
- Return that URL without placing it in a durable public record or application log.
- Let the browser download the bytes directly from storage.
The URL is a bearer credential in practice. Its lifetime should be long enough for the expected download, but it should never become the only authorization check. Set the response disposition deliberately when a download needs a stable filename; Content-Disposition describes whether a browser should display content inline or treat it as an attachment. It's a small header, but a wrong default can make a private image preview behave like an unwanted attachment or expose a filename that contains more tenant information than intended.
Here is the critical state transition in Python. The same ordering applies in a Node.js worker: do not mark the variant ready until the write has completed and the resulting key is recorded.
from dataclasses import dataclass from datetime import datetime, timezone @dataclass class Asset: tenant_id: str job_id: str object_key: str state: str expires_at: datetime def can_issue_download(asset: Asset, tenant_id: str, now: datetime) -> bool: return ( asset.tenant_id == tenant_id and asset.state == "ready" and now < asset.expires_at ) def mark_ready_after_upload(asset: Asset, uploaded_key: str) -> Asset: if uploaded_key != asset.object_key: raise ValueError("uploaded key does not match the asset record") return Asset( tenant_id=asset.tenant_id, job_id=asset.job_id, object_key=asset.object_key, state="ready", expires_at=asset.expires_at, ) now = datetime.now(timezone.utc) That function does not prove the upload exists; the storage adapter must check the write result and surface failure. It does show the boundary I want reviewers to see: authorization comes from application state, not from a path that happens to contain a tenant ID.
Keys, records, and the upload boundary
The first failure mode is treating signed-link expiry as retention. A link that expires in ten minutes can point to an object that remains for a year. For generated images, that leaves abandoned outputs and thumbnails accumulating even though no current URL can reach them. Store a retention class on the asset row and run deletion from that record.
The second is deleting only the original. A thumbnail is an independent object, and a resize pipeline may create several formats or widths. The deletion transaction should enumerate the keys in the asset record, mark the asset unavailable, then delete every recorded variant. A retryable deletion job should preserve enough state to distinguish “not attempted,” “deleted,” and “needs another attempt.”
The third is assuming a successful generation means successful persistence. The model call, image transformation, object write, database update, and link issuance are different operations. A 200 response from the generation step says nothing about the upload step. Keep these states visible in logs and metrics: generated, uploaded, cataloged, ready, expired, and deleted.
Picture a retry after a worker has uploaded the bytes but before it has committed the catalog row. If the retry invents another key, the tenant now has two images and the cleanup worker has no authoritative answer about which one is the thumbnail. If it reuses a key without coordinating ownership, two transforms can overwrite one another. The durable job row is the answer: claim it once, persist the physical key before acknowledging the message, verify the write, and make the catalog transition idempotent. That sequence adds a few database fields, but it removes the much harder job of reconstructing intent from bucket listings after a deploy or a queue redelivery.
The fourth is letting two workers write the same logical variant. Without conditional-write guarantees, coordinate ownership in the queue or database. Claim the resize task once, use a deterministic key, and make the catalog update idempotent. If the storage service cannot provide versioning or object lock, do not present a naming convention as immutable retention. Use a service with those guarantees when legal hold, WORM records, or recovery from overwrite is a requirement.
One more boundary: a lifecycle rule is a coarse cleanup tool. It is suitable for a temporary prefix whose objects may remain for at least the rule's minimum window. It is not suitable for a preview promised to disappear after an hour. That needs an application worker or scheduled job, plus a download handler that refuses an expired record even if cleanup has not run yet.
Deletion is a governance contract
Deletion should be a state machine, not a best-effort DELETE hidden inside a cron script. At creation time, assign the asset an expiry policy. When the policy is reached, mark the record deletion_pending so new download requests stop. A worker reads the recorded keys, deletes them, records each outcome, and moves the row to deleted only when the intended set has been handled. A failed attempt stays visible and retryable.
For a user erasure request, query the database by tenant and user, include every original and derivative key, then run the same deletion workflow. GDPR Article 17 makes erasure a user-facing obligation in applicable cases; a prefix convention does not demonstrate that all variants were found. Keep an audit record of the request and outcome without retaining the image bytes or the signed URL.
The useful comparison is the whole delivery path.
| Design | Good fit | Cost or control trade-off |
|---|---|---|
| App-server disk | Small internal tools with no durable download requirement | Deploys, restarts, and multiple instances make file retention the web tier's problem |
| Database blob storage | Small files tightly coupled to transactional records | Large image reads compete with database workload and backup capacity |
| Private object storage plus signed URLs | Tenant-scoped images and thumbnails delivered after authorization | Requires an asset catalog, link issuance logic, and cleanup jobs |
| On-demand transformation from one original | Many unpredictable variants or frequently changing crop rules | Each read may pay transformation latency and compute cost |
For this B2B SaaS scenario, the third row is the default because delivery is separated from the Node.js process and each variant can have an explicit retention class. The choice is not suitable when the product requires strong object immutability, multi-region replication, searchable object metadata, or a browser-direct upload control plane that the chosen storage service cannot provide. In those cases, select a storage platform that explicitly supplies the missing guarantee, even if its integration surface is larger.
The catch is operational ownership. A private bucket removes public exposure, but it does not create a deletion policy, a restore plan, or tenant isolation by itself. Teams that do not want to own an asset catalog and cleanup worker should choose a higher-level media service, accepting its abstraction and migration constraints.
Signed delivery is a separate capability
Testing should include two tenants, a duplicate resize message, a worker restart after upload, an expired link, a deletion retry, and a late download request. The expected result is boring: the wrong tenant is denied, one job does not create competing authoritative variants, an expired asset cannot receive a fresh link, and cleanup can resume without guessing what happened.
I am not sure a storage benchmark can answer the real question here. Your mileage may vary with image volume, geography, and transformation frequency. Measure those inputs, but make access control and deletion correctness release gates rather than spreadsheet footnotes.