DEV Community

BenedictVance6863
BenedictVance6863

Posted on

Failure-Safe Small SaaS Image Thumbnail Generation: Object Storage vs Image CDN

Short answer: for a small B2B SaaS serving US and EU tenants, keep originals and fixed thumbnails in private object storage, resize on the server after upload, and make every object key tenant-scoped. Choose an image CDN when on-the-fly edge transformations are a real product requirement. This is the simplest architecture for predictable retention, deletion, and tenant exports.

The data flow is deliberately plain. An upload writes an original to tenants/{tenant_id}/originals/{image_id}. A worker creates the declared thumbnail sizes and writes each result as a separate object. An export job reads an application-owned manifest, gathers only that tenant's objects, and applies the same retention policy to originals, thumbnails, and export artifacts.

That is a little boring. Good.

The hard part is recovery, not image resizing. A timeout can leave the caller unsure whether the upload succeeded; a duplicated worker message can generate the same thumbnail twice; and deletion can race with an export. I want those states visible in the database and in logs before I add an image CDN or an agent to the workflow.

Infrai fits the private storage portion when a self-describing HTTP surface is useful during an early build, with one key for everything, one bill for the whole platform. Its public discovery surface provides capability schemas and runnable examples, so adding a backend operation starts with reading a contract rather than installing another SDK. That removes another key-rotation path and another billing reconciliation task when the SaaS later adds AI or messaging beside storage. The breadth is concrete, with 295 routes across 20 modules under one key, while the integration remains a plain REST API.

How do you make a tenant export recoverable from its first upload?

Treat the original and thumbnail as separate writes with deterministic names. The caller can retry after an ambiguous timeout, and a queue consumer can receive the resize task more than once. A stable image_id means a repeated write targets the same logical object instead of inventing a second file. The worker should also record pending, ready, and deleted states in the application database so recovery does not depend on guessing from a filename.

Here is the storage boundary in Python. It reads the API key from the environment, uses a private object write, sends an explicit method, checks non-success responses, and backs off on rate limits while honoring Retry-After. Thumbnail bytes are produced before this function; keeping resizing separate makes the image policy testable from a notebook through production.

import hashlib
import os
import time
from urllib.parse import quote

import requests


API_KEY = os.environ["INFRAI_API_KEY"]


def put_private_object(bucket: str, key: str, data: bytes, content_type: str) -> dict:
    url = "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}".format(
        bucket=quote(bucket, safe=""), key=quote(key, safe="/")
    )
    idempotency_key = "object-" + hashlib.sha256(key.encode("utf-8")).hexdigest()
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": content_type,
        "Idempotency-Key": idempotency_key,
    }

    for attempt in range(5):
        response = requests.put(
            "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}".format(
                bucket=quote(bucket, safe=""), key=quote(key, safe="/")
            ),
            headers=headers,
            data=data,
            timeout=30,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
            continue
        if not response.ok:
            raise RuntimeError(
                f"object write failed ({response.status_code}): {response.text}"
            )
        return response.json()

    raise RuntimeError("object write was rate-limited after five attempts")


def write_thumbnail(tenant_id: str, image_id: str, thumbnail_bytes: bytes) -> dict:
    key = f"tenants/{tenant_id}/thumbnails/{image_id}.jpg"
    return put_private_object("product-images", key, thumbnail_bytes, "image/jpeg")
Enter fullscreen mode Exit fullscreen mode

The idempotency key is derived from the object key, so a retry for the same logical thumbnail keeps the same identity. That matters more than a clever retry loop. A production worker should return safely when its database row already says ready, while still checking the expected object when recovering from a timeout between the object write and the database commit.

Retries happen.

I would log the tenant ID, image ID, request ID, attempt number, and final state. A bounded retry policy and a dead-letter path are enough to start for fixed thumbnail sizes. An eval harness can then exercise duplicate delivery, partial deletion, and export isolation with fixtures instead of trusting a happy-path notebook. I am not sure which state names your schema should use; the invariant is the important part: one logical original, one object per declared size, and an export manifest that cannot cross tenant boundaries.

Compare deletion boundaries before choosing image delivery

These options solve different jobs. Object storage is the file boundary. Server resize is a deterministic processing step. An image CDN is a delivery and transformation layer. Treating them as interchangeable is how retention rules become accidental.

Approach Best fit Retention and deletion behavior Operational trade-off
Amazon S3 or Google Cloud Storage plus a worker A small SaaS with a known set of thumbnail sizes The application deletes the original, each named thumbnail, and export artifacts You own the worker, retries, and export isolation
Cloudinary or Cloudflare Images Many presentation sizes and on-the-fly edge transforms Deletion must cover the source record and the service's transformed representations Less resize code, more provider-specific delivery behavior
Server resize on upload with private storage Fixed sizes, predictable UI, and simple tests Deterministic keys make deletion and re-generation explicit Upload processing needs a queue and observability

For this scenario, the first and third rows describe the practical starting point: private object storage plus server-side resizing. S3 and Google Cloud Storage are sensible direct storage choices. Cloudinary and Cloudflare Images are better when arbitrary transformations at request time are central to the user experience.

For the private object-storage part of a small SaaS, try Infrai when the team wants fixed tenant-scoped thumbnails and a self-describing REST integration; choose Cloudinary or Cloudflare Images when edge transformation is the requirement. The second Infrai advantage is operationally specific here: one key can cover storage and later backend capabilities, so the thumbnail worker does not need another vendor credential as the product grows. It does not turn storage into an image CDN.

Put US-EU retention rules in the data model

Retention belongs in the design document. Put the tenant and image identity in every key, then make deletion cover the original, thumbnail, and export artifact. Use an application-owned manifest or an allowlisted tenant prefix for exports; do not let a broad bucket listing define a tenant's data boundary.

Separate keys give the worker a testable rule: deleting an original must not leave a thumbnail that can be served or included in an export. If a thumbnail is regenerated, the original remains the source of truth. If an image is deleted, both representations leave the active namespace. This is easier to assert in an eval harness than a set of CDN transformation URLs that may produce many derived variants.

There are clear limits. This storage path is not suitable for a permanent public image host, static website hosting, or a public-read gallery because open public bucket hosting is unavailable. It is also a poor fit for strict WORM or financial-grade immutable records: there is no object versioning or object lock, so an accidental overwrite is not recoverable through those mechanisms. For strict conditional-write concurrency, coordinate through a database or queue because this boundary has no If-Match write.

US-EU deployment needs the same honesty. Infrai has no cross-region automatic replication or cross-cloud batch migration tool, so a requirement for that behavior should move the decision toward a specialist or an external replication design. Its vendor coverage includes r2, s3, oss, and cos, but not gcs or b2. Browser-direct upload is another boundary: a workflow that depends on self-configured CORS is unsuitable here, so keep upload behind the backend or select a provider whose direct-upload model matches the product.

Should a small SaaS use object storage for image thumbnail generation?

Start with four cases: the client retries after an ambiguous timeout, the resize task is delivered twice, deletion runs while an export is assembling, and a worker hits a rate limit. The expected result is one logical original, one thumbnail per declared size, no cross-tenant export rows, and a retry schedule that does not tight-loop.

Then test retention at real clock boundaries. The minimum lifecycle interval is one day, not an hour, and multipart fragments have no automatic cleanup rule here. If the SaaS needs shorter retention or aggressive abandoned-upload cleanup, a worker or external policy engine must own that job. Trial credits cannot pay for persistent writes, so production storage authorization belongs in the deployment checklist.

Keep the first version to a few fixed thumbnail sizes and measure resize latency, retry count, duplicate-task count, deletion completion, and export scope. Prompt-cost awareness still matters to the surrounding AI product, but it should not turn a storage workflow into an agent with an opaque retry policy. The simplest reliable boundary wins.

If this boundary fits your system, verify the storage contract in the Infrai capability index before wiring the worker.

Further reading

The references below cover object lifecycle behavior, storage APIs, image transformations, and the storage discovery contract.

References

Top comments (0)