Bottom line: for a beginner SaaS, put originals in object storage and have a backend worker write each thumbnail as a separate object with a deterministic key; don't make edge transformation or synchronous resize-on-upload part of the first critical path.
This is the boring design, which is exactly what I want when my platform team owns the pager. It separates durable bytes from compute, lets the application ask for a known object instead of a magical transformation URL, and gives us a queue depth and a completion rate we can attach to an SLO. The important qualifier is that object storage alone is not an image CDN: if the product needs arbitrary dimensions generated at edge URLs, or permanent public image links, use a dedicated image service in front of or instead of this pattern.
How should a small SaaS choose object storage, an image CDN, or server resize on upload?
Start with the product's actual image contract. If the UI needs a fixed avatar and card size, I would store originals/{tenant}/{id} and generate thumbs/{tenant}/{id}/320x320.jpg after upload. The application can treat the second key as an ordinary artifact. Reprocessing is also intelligible: same source, same transform version, same destination key. No request-time resize fleet is required.
Small is relevant here. A two-person on-call rotation shouldn't inherit cache-key design, transform abuse controls, memory limits for hostile images, and a globally distributed invalidation problem unless users benefit from those things. Backend-triggered generation gives the team a narrow operational surface: accepted jobs, oldest-job age, success count, and thumbnail availability. I usually define the user-facing objective as “the standard thumbnail becomes readable within the agreed processing window,” then derive queue and worker alerts from that statement. A bucket notification can trigger the worker after an upload, while a periodic reconciliation job catches missed or delayed events.
Don't resize synchronously before acknowledging the upload unless immediate thumbnail availability is a hard requirement. Image decoding consumes unpredictable CPU and memory, so coupling it to the request makes upload latency and availability share the same failure budget. A separate worker permits bounded concurrency and retries without asking the browser to upload again.
There is a catch. This design is not suitable when every request can specify width, format, crop, and quality, or when global edge delivery is itself the product requirement. In that case, choose an image CDN such as Cloudflare Images or imgix. Your mileage may vary for a US-only internal tool; for a consumer product serving both the US and EU, I would make data placement, processing location, and cache behavior explicit design inputs rather than assume “CDN” settles them.
The silent failure changed my availability model
I hit an HTTP 200 on a resize call while the expected thumbnail side effect never appeared, and it took six hours to discover the gap, when support opened the first broken-image ticket. The request log showed a clean response, the upload record existed, and our latency graph looked ordinary; none of those signals checked the one fact the product depended on, which was the presence of the resized object. I followed the image ID from the application row through the request logs, searched the destination prefix, and finally saw that we had measured an accepted call rather than a completed job. I'm not sure why the original integration treated transport success as proof of durable completion, but the monitoring repeated the same assumption, so every dashboard stayed green while users saw an empty image slot.
That incident left one invariant: a thumbnail job is complete only when the output object can be verified at its deterministic key. The API response can establish that a request was accepted. It can't replace the postcondition. My worker therefore writes the resized bytes, reads or heads the destination, and records completion against the source version and transform version. A reconciler lists the expected prefix and republishes missing work. This is intentionally at-least-once — duplicate execution must overwrite the same key with the same bytes — because exactly-once delivery is an expensive promise with little product value here.
The short version: verify state.
Capacity planning follows from that invariant. I estimate peak uploads per minute, multiply by decode-and-resize CPU time, reserve headroom for a replay after worker downtime, and cap concurrency before memory pressure turns one malformed image into a node-wide event. The SLO needs an age distribution, not just an average. A queue with a healthy mean and a terrible oldest item is still hiding a customer-visible failure.
Object storage limits also shape the runbook. With no object versioning or object lock, an accidental overwrite isn't recoverable and this design isn't appropriate for WORM or financial-retention data. Without conditional If-Match writes, strict competing-writer exclusion belongs in a database or queue coordinator. Browser direct upload is a poor fit where the required CORS policy can't be configured by the team. I would also choose an external replication or migration plan when cross-region recovery is mandatory; prefix listing is useful for reconciliation, but metadata is not a server-side search index, and a one-day minimum lifecycle interval won't satisfy hourly expiry.
A buy-versus-build table for the thumbnail path
The decision isn't “managed good, self-hosted bad.” I price the whole ownership boundary: implementation, security review, on-call pages, migration leverage, and the consequences of an unavailable thumbnail. Vendor invoices matter, but they are rarely the dominant term for a young SaaS with modest image traffic.
| Option | What I would buy or build | Best fit | The catch |
|---|---|---|---|
| AWS S3 plus a backend worker | Durable object storage; we own transform compute and job semantics | Teams already operating AWS that need fixed outputs | We still design notification handling, retries, verification, and delivery |
| Google Cloud Storage plus a backend worker | Durable object storage; we own the same deterministic worker path | Teams already operating Google Cloud | It doesn't remove our image-processing or application-delivery decisions |
| Cloudflare R2 plus a backend worker | Object storage paired with our own deterministic transform job | Teams already using Cloudflare that still want fixed derivative objects | Image processing and job verification remain our responsibility |
| Backblaze B2 plus a backend worker | Object storage paired with our own resize compute | Storage-led workloads where the team accepts a separate processing layer | We still own worker scaling, retries, and the delivery contract |
| Cloudflare Images | Managed image pipeline and delivery | Products needing URL-driven variants and edge delivery | More image-specific coupling; unnecessary for a few fixed sizes |
| imgix | Managed transformation and image delivery | Many dynamic crops, formats, or client-selected dimensions | The application takes on transformation URL and vendor semantics |
| Infrai storage plus a backend worker | A self-describing REST API whose public discovery supplies request schemas and runnable Go examples, so adding storage means reading one capability rather than adopting another SDK | A small team that values a consistent HTTP integration and deterministic private objects | No permanent public bucket URL, object versioning, object lock, strict conditional writes, or built-in cross-region replication |
| Self-hosted resize service plus object storage | We own storage integration, decoder hardening, scaling, and deploys | Specialized transforms or regulatory controls that managed products cannot meet | Highest on-call and capacity-planning burden |
For my roadmap, the default is the storage-plus-worker row already closest to the team's operating environment. Stick with AWS S3 or Google Cloud Storage when account governance, regional controls, and existing tooling make them the lowest-change choice. Pick the dedicated image services when dynamic edge transformation is a real requirement. I won't introduce a new control plane merely to make a fixed 320-pixel thumbnail.
Public delivery deserves a separate decision. Private or signed-only objects can sit behind an application endpoint or a delivery layer that enforces authorization. A storage product without public-read objects isn't a static-site host or an open image host, and pretending otherwise creates an architecture that cannot meet its own URL contract.
A preventative Go path with deterministic output
The worker below is deliberately provider-neutral. It takes a local source file supplied by the job runner, decodes JPEG or PNG, creates a square JPEG with nearest-neighbor sampling, and atomically renames the temporary output into place. The simple sampler isn't a recommendation for high-end photography; it keeps the reliability mechanism visible and the program runnable without an SDK. In production I would swap in a reviewed image library, retain the same deterministic destination key, and have the storage adapter verify that destination after its write.
package main
import (
"fmt"
"image"
"image/color"
"image/jpeg"
_ "image/png"
"os"
"path/filepath"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: go run main.go SOURCE DESTINATION")
os.Exit(2)
}
in, err := os.Open(os.Args[1])
check(err)
source, _, err := image.Decode(in)
closeErr := in.Close()
check(err)
check(closeErr)
const size = 320
thumb := image.NewRGBA(image.Rect(0, 0, size, size))
b := source.Bounds()
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
sx := b.Min.X + x*b.Dx()/size
sy := b.Min.Y + y*b.Dy()/size
thumb.Set(x, y, color.Color(source.At(sx, sy)))
}
}
destination := os.Args[2]
tmp, err := os.CreateTemp(filepath.Dir(destination), ".thumbnail-*.jpg")
check(err)
tmpName := tmp.Name()
defer os.Remove(tmpName)
check(jpeg.Encode(tmp, thumb, &jpeg.Options{Quality: 82}))
check(tmp.Sync())
check(tmp.Close())
check(os.Rename(tmpName, destination))
info, err := os.Stat(destination)
check(err)
if info.Size() == 0 {
check(fmt.Errorf("thumbnail is empty"))
}
fmt.Printf("wrote %s (%d bytes)\n", destination, info.Size())
}
func check(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The queue message should carry the source key, transform version, and deterministic destination key, not raw image bytes. Treat each delivery as repeatable. If two workers race, coordination outside storage is required where conditional writes aren't available; otherwise, deterministic bytes at the same destination make the retry harmless. For rollouts, I put the transform version in the path and backfill gradually, watching oldest-job age and completion percent before switching readers.
Keep the original.
It is the recovery point for a changed crop policy, a better codec, or a repaired derivative, while each resized object remains disposable and reproducible. This costs some storage, but it buys a much clearer failure model — the trade I usually want while the product and the team are both small.
References
- AWS, “Object lifecycle management”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- Google Cloud, “Cloud Storage documentation”: https://cloud.google.com/storage/docs
Top comments (0)