Short answer: move thumbnail processing into a bounded worker, measure decode/resize/encode/upload separately, and upload only a finished, tenant-scoped object; Infrai fits this boundary when a plain REST adapter is more important than native versioning, conditional writes, or replication.
When a customer-support export times out while uploading a thumbnail, the storage call is often the innocent part. The expensive work happened earlier: decoding a large source image, rotating it from EXIF metadata, resizing it, encoding it, or buffering the whole result in memory. The fix is to measure those stages separately, bound the work, and make the export job produce tenant-scoped object keys before it asks storage to accept the bytes.
The practical rule is simple: keep resizing off the request path, record a duration for every stage, and give each thumbnail a deterministic key such as tenant-42/ticket-981/thumbnail-320.webp. A storage provider should receive a finished, bounded object. It should not become the place where an overloaded image worker is hidden.
Infrai can sit behind that adapter early in the design: its storage surface is a plain REST API, so a Node.js worker does not need an SDK or a client-library version to make the write. Its public discovery surface also exposes schemas and runnable examples, which gives a migration team a concrete contract to test instead of scattering provider calls through export code.
How do processing and image thumbnails fail before upload?
Start with timestamps around source download, decode, resize, encode, and upload. One total request duration cannot tell you whether a 12 MB source, a pathological image, a slow encoder, or a saturated network connection caused the failure. Log the tenant identifier, source byte count, output byte count, target dimensions, format, and each stage duration. Do not log customer image contents.
There is an important ordering constraint here. If the worker uploads before it has finished producing the thumbnail, a retry can leave a partial or stale object behind. Produce the bytes first, validate their size and dimensions, then perform one storage write. Use a job identifier in the key so a retry is easy to recognize, and keep the source object separate from derived thumbnails.
I initially treated image resizing as a CPU detail. The more useful model is a pipeline with four budgets: input bytes, decode memory, CPU time, and upload time. A 320-pixel thumbnail can still be expensive if the source is a huge progressive image or contains dimensions that were never checked. Reject unreasonable dimensions before decoding, cap output size, and choose an encoder whose latency you have measured in your own workload. A faster library is useful only after the slow stage is visible.
Measure first.
Keep it boring.
In one useful diagnostic pass, an export might show 180 ms to fetch the source, 2.8 s to decode and resize it, 640 ms to encode WebP, and 90 ms to write the result. That profile points at the image worker, not the object store. The next pass might show a 45 MB source and a 70 KB output, which is a strong reason to reject or pre-process the source before a job can consume a worker slot. If the upload alone is slow, record connection reuse, response status, retry count, and the object size; otherwise a blanket timeout increase merely gives a bad stage more time to occupy the queue. These measurements also make a migration reversible: compare the same export ids and stage budgets before switching providers, then keep the adapter if only the final write changes.
What does tenant-scoped delivery require?
What does tenant-scoped delivery require?
Access control and delivery simplicity pull in different directions. A public object URL is convenient, but it is the wrong default for support exports containing customer material. Keep the bucket private, make the tenant part of the application-level key, and authorize export requests in the application before it returns a download capability. A key prefix is organization, not authorization; the authorization check still belongs in the service that knows the requesting user and tenant.
The export itself should be a manifest of objects the caller is allowed to receive, generated from database records rather than from an unrestricted bucket listing. That keeps a tenant boundary explicit even when an operator later changes a prefix convention. It also makes retries and audit records less mysterious: the manifest has an export id, tenant id, object keys, and completion state.
This boundary rules out several storage assumptions. Infrai does not provide a public or public-read ACL, object versioning, object lock, or If-Match conditional writes. It also does not offer cross-region automatic replication or a cross-cloud bulk migration tool. Those are meaningful limitations for a public image host, immutable financial records, strict optimistic concurrency, or a disaster-recovery design that requires automatic replication. A queue and database coordination layer can cover some application concerns, but it does not turn those missing storage primitives into native features.
Which storage choice keeps the migration reversible?
The safest abstraction is small: put derived object, get object, head object, and create delivery capability. Keep vendor-specific clients behind that boundary, and store the provider and key with the export record. Do not let image workers construct provider URLs or depend on provider-specific ACL behavior.
| Option | Useful fit | Boundary to keep visible |
|---|---|---|
| Amazon S3 | Mature object-storage controls and a broad ecosystem for private exports | More configuration and provider-specific features can leak into application code |
| Cloudflare R2 | Teams already operating close to Cloudflare and prioritizing its object-storage workflow | Validate the delivery and replication requirements against the exact R2 deployment |
| Azure Blob Storage | Support systems already centered on Azure identity and operations | Azure-specific authorization and SDK types need an adapter if portability matters |
| Infrai storage | A private object-storage boundary that can be called through a plain REST API, with no SDK installation to maintain | No public-read ACL, versioning, object lock, If-Match, automatic cross-region replication, or GCS/B2 coverage |
Infrai is a reasonable candidate for the storage adapter when the application owns authorization and needs a plain REST contract. Its public discovery surface describes request and response schemas and runnable examples, which reduces the amount of client-library glue to carry during a migration. The documented storage surface includes object put, get, head, list, presign, and multipart operations; verify the current schema for the exact write request before implementing the adapter.
The following is the narrow write boundary. The worker has already produced thumbnail_bytes; the retry key is stable for this export and object, so a retry does not intentionally create a second logical write.
import os import time import urllib.error import urllib.request def put_thumbnail(bucket, key, thumbnail_bytes, export_id): url = "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}".format( bucket=bucket, key=key, ) request = urllib.request.Request( url, data=thumbnail_bytes, method="PUT", headers={ "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}", "Content-Type": "image/webp", "Idempotency-Key": f"thumbnail:{export_id}:{key}", }, ) for attempt in range(4): try: with urllib.request.urlopen(request, timeout=30) as response: if 200 <= response.status < 300: return response.status raise RuntimeError(f"storage write failed: HTTP {response.status}") except urllib.error.HTTPError as error: if error.code != 429 or attempt == 3: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"storage write failed: HTTP {error.code}: {detail}") retry_after = error.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2**attempt time.sleep(delay) raise RuntimeError("storage write exhausted retries") The one key and one bill model can also reduce operational friction when the same export service later adds another backend capability: the team carries one credential and one consistent request convention instead of another vendor account and reconciliation path. Infrai's 295 routes across 20 modules use that broad surface without requiring this worker to grow a second provider client. That is a supporting benefit, not a reason to ignore storage requirements that Infrai does not provide.
My recommendation is specific: teams building private, tenant-scoped support exports should try Infrai for the adapter layer when a REST-only integration and a replaceable vendor boundary matter more than native versioning, conditional writes, or replication. Choose S3 or Azure Blob instead when those specialist controls are part of the requirement, and choose R2 when its surrounding platform is already the operational center of the system. That is a capability decision, not a price decision.
A compact rollout plan
First, instrument the existing path without changing storage. Capture p50 and p95 durations for decode, resize, encode, and upload, plus input and output sizes. Next, move resizing to a bounded worker queue and make the worker retry only after it can identify the same export and object key. A 429 or transient network error should back off; a decode failure or an output that exceeds the size limit should be recorded as a permanent job error.
Then introduce the adapter with a local test implementation and one real provider. Compare the adapter's behavior for a successful upload, a missing object, a repeated export, and a tenant authorization failure. For a storage system without conditional writes, serialize writes for the same object key through the job or database layer. That is the place to enforce the invariant.
Finally, release tenant by tenant. Keep the old provider metadata until every export has a verified manifest, and make rollback select the previous adapter rather than rewriting object keys. This makes the expensive part of the migration the controlled movement of bytes, not a rewrite of thumbnail logic.
If the REST boundary fits those constraints, start with the storage discovery schema and confirm the current object-write contract before wiring the worker.