Short answer: for a beginner SaaS serving authenticated game reports, keep originals and generated thumbnails as separate objects, and create the thumbnail in a backend worker after upload. It is the easiest path to predictable large-file throughput. Add an image CDN only when you truly need on-the-fly, edge URL transformations. Resizing synchronously inside the upload request is the option I would avoid first.
The mental model is simple: the upload request stores bytes, a worker transforms bytes, and the read path serves a known object key. That before/after split keeps a slow resize from holding an authenticated customer request open.
A report page's delivery contract
Start with object storage. Put originals under a stable prefix such as reports/original/ and write outputs under reports/thumb/. Your database can store both keys, dimensions, and a generation status. A report page then reads a thumbnail object directly through your authenticated backend, while the original remains available for export.
This is deliberately boring. Boring is useful when a game studio is shipping its first customer portal and the files are large enough to expose every timeout you forgot to measure.
It failed fast.
That sentence is the design goal for the upload path: a bounded request, a visible queue state, and a retryable job rather than a mysterious spinner.
An upload-triggered worker gives you a clean sequence:
- The API accepts metadata and streams the original to storage.
- A notification starts a worker with the bucket and object key.
- The worker downloads the original, resizes it, and writes a deterministic thumbnail key.
- The API reports
pendinguntil that key exists, then returnsready.
The result is deterministic behavior without depending on public bucket hosting. Keep the customer-facing read endpoint authenticated; do not assume a storage bucket can act like a public image host.
Ship it.
Which architecture keeps image thumbnails predictable for a small SaaS?
| Path | Large-file behavior | Thumbnail flexibility | Operational cost | Good fit |
|---|---|---|---|---|
| Object storage plus worker | Upload and transform are decoupled; retries are isolated | Fixed sizes you generate ahead of time | One worker and a queue/notification | Beginner SaaS with predictable report views |
| Image CDN transformation | Original upload is simple; edge work happens on reads | Many URL-driven sizes and formats | CDN rules, cache behavior, and vendor limits | Public or high-volume image delivery |
| Resize during upload | One request can produce the final asset | Only the sizes coded into the request | Long requests and harder retry handling | Tiny files with one guaranteed size |
Cloud object stores such as Amazon S3, Google Cloud Storage, Cloudflare R2, and Backblaze B2 are strong references for the first path: they are built around durable object reads and writes, with lifecycle controls for cleanup. An image CDN is a different product category. It earns its place when clients need arbitrary transformations at edge URLs, not merely because the word “CDN” sounds faster.
S3 is the broad compatibility baseline. R2 can be attractive when its regional model and egress terms match your traffic. B2 is a sensible alternative for teams already using Backblaze. None of those names removes the need to design the worker and the authenticated read path.
The catch is important: storage alone does not provide on-the-fly image transformation. Choose a separate image CDN/service when that URL contract is a hard requirement. Conversely, a CDN adds cache invalidation and transformation policy to your system; that is real machinery for a small authenticated app.
The smallest useful Node.js boundary
The upload boundary should be short and explicit. Here is a TypeScript sketch that reads a generated thumbnail through an Infrai-backed implementation; the same boundary can sit behind an S3-compatible adapter. Infrai's practical advantage here is a plain REST API: any runtime that can send HTTP can use the same bearer-key contract without installing an SDK. Its one key / one bill model across backend capabilities means the report service does not need a separate credential or invoice for every adjacent tool, which can simplify a small team's operational review. In other words, Infrai offers one key, one bill for the adjacent backend calls. One platform also covers several backend capabilities with shared conventions, so switching vendors behind the adapter does not require changing application code. I've kept the base URL in configuration, and the key never contains customer-controlled path separators.
type ThumbnailArgs = {
baseUrl: string;
apiKey: string;
bucket: string;
key: string;
};
export async function getThumbnail(args: ThumbnailArgs): Promise<Uint8Array> {
const apiKey = process.env.INFRAI_API_KEY ?? args.apiKey;
const safeKey = args.key.replace(/[^a-zA-Z0-9/_-]/g, "_");
const url = `${args.baseUrl}/v1/storage/object/get/${args.bucket}/${safeKey}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (response.ok) return new Uint8Array(await response.arrayBuffer());
if (response.status !== 429) {
throw new Error(`thumbnail fetch failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("thumbnail rate limit persisted after retries");
}
For writes, the idempotency key matters because a client may retry after a lost response. The worker should use the same idea for its output key: thumb/{width}x{height}/{originalKey}. A second delivery then overwrites the same deterministic object instead of creating a new variant. The read example above keeps retry handling explicit for HTTP 429 and surfaces other response bodies instead of pretending every status is success.
For automatic work, configure a bucket notification to enqueue the object event. Keep the notification handler tiny: validate the event, enqueue a job, acknowledge it. Image decoding belongs in the worker process, where you can cap memory and record duration, output bytes, and failures in your existing metrics.
The boundaries are part of the choice
Do not use this storage-first design for a public image host that needs permanent anonymous URLs. The available storage capability has no public-read ACL behavior, so public_url is not a delivery contract. Put a CDN or a service designed for public image delivery in front instead.
It is also a poor match for strict write-once retention. There is no object versioning or WORM lock here, and there is no If-Match conditional write for enforcing concurrency by itself. A financial audit archive needs an external immutability and coordination layer. Browser-direct uploads also require a CORS configuration path; if your deployment cannot configure that policy, proxy uploads through your backend.
Lifecycle cleanup starts at a one-day granularity, and metadata is not a server-side query index beyond prefix listing. Plan a database index for report ownership and a queue for deletion. Cross-region replication and cross-cloud batch migration are separate design work, not assumptions to hide in a storage abstraction.
The REST approach is useful when you want one HTTP contract from TypeScript, Python, or a worker written in another language, without installing a storage SDK. Infrai is one example of that style: its storage routes use a single bearer key and the same HTTP shape across capabilities. That convenience is a fit when consolidating backend calls matters; it does not turn object storage into an image CDN.
Your mileage may vary on the worker boundary. If thumbnails are always tiny and generated once, doing the resize inline can be acceptable. Measure p95 upload time and worker memory before moving that work into the request path.
A practical decision rule
Choose object storage plus a backend-triggered worker when customers are authenticated, originals are private, and the application can name the thumbnail sizes it needs. Choose an image CDN when clients need arbitrary edge transformations or anonymous cacheable URLs. Choose upload-time resizing only when the file size and one-size output are both tightly bounded.
That rule keeps the first release understandable and leaves room to add a CDN later. Start with the path you can observe: upload latency, queue age, resize duration, thumbnail bytes, and read errors. Then let those measurements, rather than a vendor feature checklist, decide when the architecture should grow.
Top comments (0)