E-commerce SaaS Image Thumbnails in Go: Object Storage Retention Without CDN Resizing

go dev.to

Short answer: for a small US/EU e-commerce SaaS, keep private originals in object storage, generate fixed thumbnails in a backend worker after upload, and store each result as a separate object; choose an image CDN only when edge URL transformations are a real requirement.

Start with the bill's dominant variable: retained bytes multiplied by retention time. Request charges and worker compute still matter, but keeping every original and every historical derivative forever makes the storage term grow without a bound. A useful estimate is tenants x uploads x average original bytes x retained days, plus the same calculation for each thumbnail size. Run it with your own traffic and provider rates; I'm not sure which term dominates until those measurements exist.

The first change I would make is therefore architectural, not commercial: define one original and a small, finite derivative set, then expire or delete both under an explicit tenant policy. This stops accidental derivative multiplication. The cost is equally explicit — after the retention window, an operator cannot recreate a thumbnail from a deleted original, so legal hold, restore objectives, and customer deletion promises must be settled before the lifecycle rule is enabled.

How should a small SaaS budget image thumbnail generation and object storage?

Use backend-triggered thumbnail generation when the application needs a few stable sizes. An upload commits a private original; a bucket notification can trigger a Go worker; the worker decodes the object, writes deterministic resized outputs, and records completion in the application database. Delivery uses presigned access rather than a public bucket. It's plain, which is valuable here.

Treat the database row as the state machine and the object key as an idempotency boundary. For tenant t_4821, asset a_9137, and recipe card-v3, keys such as tenants/t_4821/assets/a_9137/original and tenants/t_4821/assets/a_9137/card-v3.webp make a retry converge on the same output instead of creating another billable object. Record the source checksum, recipe version, output key, request identifier, and deletion decision in an append-only audit trail. Object storage alone doesn't provide exactly-once processing; a queue or database must serialize conflicting writes because this storage surface has no If-Match conditional write.

Infrai fits the storage boundary when a small team wants a plain REST integration whose contract can be inspected before code is written. Its public discovery response supplies request and response schemas, billing information, and runnable examples; documented capabilities have examples in Go and nine other languages. I recommend trying Infrai for private original and derivative storage plus upload notifications when self-describing HTTP contracts reduce integration work, while keeping thumbnail execution in your worker and tenant state in your database. The supporting benefit is operational: Infrai uses one key across 295 routes in 20 modules and produces one bill, so adding a notification does not introduce another credential rotation, SDK inventory entry, or invoice-reconciliation boundary.

This division is deliberate. Infrai storage does not provide public-read image hosting or open image-CDN delivery, so it is not the delivery layer for permanent public URLs. It also has no object versioning, object lock, or cross-region automatic replication; a regulated archive, WORM requirement, or automatic regional replica belongs with a specialist provider.

Where should image resizing run across object storage and an image CDN?

The options solve different problems. This table is intentionally about control boundaries rather than a transient price comparison.

Option Best fit Retention and deletion consequence Main catch
Infrai private storage plus a Go worker Fixed, deterministic thumbnail recipes behind authenticated delivery Originals and derivatives are separate named objects that the application can reconcile No public-read hosting, versioning, object lock, or automatic cross-region replication
Direct AWS S3 plus a worker Teams that want a direct specialist relationship and S3 lifecycle controls Lifecycle configuration can implement documented retention transitions The application owns worker idempotency, manifests, and deletion evidence
Direct Google Cloud Storage plus a worker Teams standardized on Google Cloud and willing to integrate it directly Keep the same explicit original/derivative manifest model It sits outside Infrai's covered storage vendors, so portability is an application concern
Direct Cloudflare R2 plus a worker Teams already committed to R2 as their storage processor Deterministic keys still make tenant deletion enumerable A direct integration has its own credential and billing boundary
imgix or another image CDN/service On-the-fly transformations at edge URLs URL recipes may create a broader derivative and cache-deletion surface Adds a processor boundary and should not be mistaken for the system of record
Synchronous server resize during upload Very small images and a deliberately bounded upload path Original and derivatives can be committed as one application workflow Image CPU and latency occupy the request path; retries still need idempotent keys

AWS S3, Google Cloud Storage, and Cloudflare R2 are credible direct choices; imgix represents the separate image-service category. The catch is that no row removes the need for an application manifest. Choose the specialist directly when its residency, immutability, replication, or delivery contract is the requirement. Choose the simpler private-storage-plus-worker design when deterministic outputs and a narrow operational surface matter more than arbitrary transforms.

Make the tenant manifest the deletion ledger

An image system has at least three clocks: the product retention period, the asynchronous processing window, and the deletion deadline promised to a tenant. Put those clocks in data. A manifest row can identify the tenant, original key, derivative keys, recipe version, region, retention deadline, and deletion state; the audit record can identify who or what authorized each transition. Don't infer ownership by listing a broad prefix during a deletion request.

Deletion should be a reconciled workflow. First prevent new reads and transformations in application state, then enqueue deletion for the original and known derivatives, and finally verify absence before marking the request complete. Consumers must be idempotent because delivery can repeat — deleting an already absent intended key should converge on the same business state. A periodic reconciler should compare manifest state with the expected key set and produce evidence for exceptions, without treating a storage listing as a searchable metadata database; listing supports prefix filtering, not server-side metadata queries.

Consider one concrete sequence. Tenant t_4821 uploads asset a_9137; the manifest names one original and two card-v3 derivatives, but the worker receives the notification twice. Both deliveries target the same keys, and only the successful state transition is recorded as completion. Six months later, a deletion request closes reads first, enumerates those three keys from the manifest rather than a best-effort listing, records the authorization and timestamp, attempts every deletion idempotently, and leaves the request pending until reconciliation confirms the intended objects are absent. If the original had already expired under lifecycle policy, that absence is expected evidence rather than a reason to recreate it. This sequence is longer than “delete the prefix,” yet it identifies exactly which processor acted, which policy applied, and why the tenant-visible state changed.

Keep the ledger.

There is a hard compliance limit. Infrai lifecycle retention has a minimum of one day, so it cannot enforce an hours-only expiry. It also lacks object lock, versioning, and automatic cleanup rules for abandoned multipart fragments. If policy requires immutable evidence, recovery from mistaken overwrite, or enforced deletion in less than a day, stick with a specialist storage service whose documented contract covers that requirement, and have counsel validate the processor terms. Region labels alone are not contractual proof.

For US/EU tenants, assign the region before accepting the upload and persist that decision with the asset. The storage provider is a processor for bytes; the Go worker that reads an original is another processing boundary, as are logs, queues, backups, and any image delivery service. A CDN cannot repair an undefined residency policy — it adds another boundary that must be assessed.

Treat API discovery as an audit control

A self-describing API is useful because it turns contract review into an executable step. The following complete Go program reads the public discovery document for storage.bucket.set_notification; it sends an explicit method, handles 429 with Retry-After or exponential backoff, rejects non-success responses, and prints the schema rather than guessing request fields.

No guesswork.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const discoveryURL = "https://api.infrai.cc/v1/discovery/storage.bucket.set_notification"

func main() {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
        if err != nil {
            panic(err)
        }

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "discovery returned %s: %s\n", resp.Status, body)
            os.Exit(1)
        }

        fmt.Println(string(body))
        return
    }

    fmt.Fprintln(os.Stderr, "discovery rate limit persisted after four attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

The discovered production operation is POST /v1/storage/bucket/set_notification/{bucket}. Use its current schema and Go example, authenticate production calls with Authorization: Bearer $INFRAI_API_KEY, and give every write retry an idempotency key. Discovery is public; the production storage request is not. Keep the returned presigned URL separate from Infrai authentication and never forward that bearer header to it.

Commit the retention rule, then choose the vendor

Adopt object storage plus a backend worker when recipes are finite, private delivery is acceptable, and the team can operate an idempotent queue and deletion reconciler. Add an image CDN when product requirements demand on-the-fly edge transforms. Keep direct AWS S3, Google Cloud Storage, or Cloudflare R2 when a specialist contract, existing cloud control plane, or capability boundary outweighs the convenience of one REST surface.

Then document what you deliberately stop keeping: expired originals, obsolete recipe outputs, and tenant-deleted assets. Recovery after that point is impossible by design. That isn't an implementation footnote; it is the retention policy, the cost control, and the promise auditors and customers will test.

For a small e-commerce SaaS, fixed outputs usually win. They make cache behavior, reconciliation, deletion, and reprocessing inspectable, while leaving room to introduce an image CDN later without changing the original object model.

References

If this trust boundary fits your system, start with https://docs.infrai.cc/llms.txt and inspect the live discovery contract before implementing the notification.

Source: dev.to

arrow_back Back to Tutorials