Short answer: upload each original image from the browser, record it in the application database, and let a Node.js queue worker generate thumbnails asynchronously; use object storage notifications as a trigger, not as the source of truth.
The page should fire when a tenant-scoped export is waiting on images, not only after a customer downloads a broken archive. On-call needs the tenant ID, export ID, oldest unfinished image, database status, queue age, original object key, and expected thumbnail keys in one view. If that page says only "thumbnail failed," the investigation has already started with missing context.
The least complex production design has three object prefixes: originals/, processing/, and thumbs/. The browser handles the original upload. A backend worker owns decode, resize, and thumbnail writes. The database owns workflow state and the list of thumbnail keys included in an export.
This split matters most at deletion time. Object metadata isn't a queryable job database, prefix listing is the available server-side selection mechanism, and an overwritten original cannot be recovered through object versioning here. Treat keys as immutable, make every queue delivery idempotent, and coordinate deletion with database state.
What should the first alert say about a browser image upload and Node.js queue?
Start at the customer-visible symptom: an export cannot become ready because one or more product images lack the required thumbnail variants. The page should identify the blocked export and point to the earliest incomplete state transition. It should not ask on-call to infer tenant impact by searching an entire bucket.
A useful state model is small: uploaded, queued, processing, ready, delete_pending, and deleted. Store that state beside the tenant ID, original key, required variants, generated keys, and a stable job ID. The precise database schema depends on the application, so I'm not sure a universal column set would help; what resolves that uncertainty is the export contract itself. If an export requires a 320-pixel and a 1280-pixel variant, those expected outputs belong in durable application state before work begins.
The alert should then work backward. A missing thumbnail is late evidence. The earlier signal is the age of the oldest database row that is uploaded or queued but not ready, joined to an export that actually needs it. Queue depth alone is weak: a burst can be healthy, while a queue of one can block a high-value tenant export for hours. Alert on stalled progress and user impact, with queue age and worker outcomes as supporting signals.
Keep it actionable.
The runbook action is deterministic: inspect the row, derive the same job ID, verify whether the expected keys are recorded, and redeliver the job only if the state machine permits it. Don't invent a second manual path that writes thumbnails outside the worker. That kind of emergency shortcut creates the duplicate or orphaned objects the state machine was meant to prevent.
Build the upload-to-thumbnail path around durable state
The browser should upload only the original. For a private object store, issue a presigned upload URL from the backend, send the file to that returned URL, and never attach the Infrai bearer token to the presigned request. The API key stays on the server. After upload completion, either the application queues a job or a bucket notification calls a thin webhook that validates the event and queues the same job shape.
The webhook is an adapter, not a worker. It shouldn't download an image, resize it, or wait for every thumbnail write. Acknowledge valid input after durable enqueue, then let the worker fetch the original, generate every required variant, write them under thumbs/, and commit the chosen keys to the database. If a notification and the application enqueue race, both must converge on one logical job.
Use an immutable original key such as originals/{tenant_id}/{image_id}/{upload_id}. The corresponding job ID can be derived from the tenant, image, upload, and transformation-set revision. Before writing, the worker checks durable state; after all variants are present, it atomically marks that revision ready and records their keys. Standard queues should be treated as at-least-once, so duplicate delivery is normal operating input rather than an exceptional incident.
There is a harder race hidden in replacement and deletion. Suppose a user uploads a new product image while an older thumbnail job is still running, then immediately starts an export. Since strict conditional writes with If-Match aren't available, the object store cannot arbitrate which generation wins. The queue and database must do it: give each upload a new immutable key and revision, have the worker verify that its revision is still current before publishing database state, and have the exporter select only the keys attached to the current ready revision. An old worker may finish its private object writes, but it cannot make those objects current. Cleanup can remove them later by prefix and recorded revision.
No overwrite recovery exists through object versioning or object lock, so reusing originals/{tenant_id}/{image_id} is the wrong shortcut. One accidental replacement would destroy the evidence needed for a retry or postmortem. Immutable source keys cost some cleanup bookkeeping, but they turn a dangerous overwrite into an ordinary stale revision.
Here is the worker contract I would put in the runbook. It is intentionally about state and ownership rather than a vendor-specific SDK:
- Load the job row by its stable ID and exit successfully if that revision is already ready or deleted.
- Claim the revision through the queue or a database transaction; don't use object writes as a lock.
- Fetch the private original, decode it, and generate the declared variants.
- Write deterministic thumbnail keys, then record those exact keys and mark the revision ready in one database transition.
- Recheck deletion intent before making the revision visible to exports.
- Retry rate limits with exponential backoff and honor
Retry-After; never spin on HTTP429.
That sequence is boring on purpose. It gives replay a single meaning.
The following runnable Go worker covers the storage boundary without assuming an SDK or an undocumented response envelope. It downloads a private original, creates a 320-pixel-wide JPEG with the standard library, and writes a deterministic thumbnail key. Set INFRAI_BASE_URL, INFRAI_API_KEY, BUCKET, ORIGINAL_KEY, and THUMB_KEY in the worker environment. Keep THUMB_KEY tied to the immutable upload revision, and use the queue's stable job ID as JOB_ID.
package main
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func required(name string) string {
value := os.Getenv(name)
if value == "" {
panic("missing environment variable: " + name)
}
return value
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(header); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Second << attempt
}
func call(client *http.Client, method, target, key, jobID string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if method == http.MethodPut {
req.Header.Set("Content-Type", "image/jpeg")
req.Header.Set("Idempotency-Key", jobID)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("storage request returned %s: %s", resp.Status, responseBody)
}
return responseBody, nil
}
return nil, fmt.Errorf("storage request remained rate limited after retries")
}
func resizeWidth(source image.Image, width int) image.Image {
bounds := source.Bounds()
height := bounds.Dy() * width / bounds.Dx()
thumb := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
sx := bounds.Min.X + x*bounds.Dx()/width
sy := bounds.Min.Y + y*bounds.Dy()/height
thumb.Set(x, y, color.Color(source.At(sx, sy)))
}
}
return thumb
}
func main() {
base := strings.TrimRight(required("INFRAI_BASE_URL"), "/")
key := required("INFRAI_API_KEY")
bucket := url.PathEscape(required("BUCKET"))
originalKey := url.PathEscape(required("ORIGINAL_KEY"))
thumbKey := url.PathEscape(required("THUMB_KEY"))
jobID := required("JOB_ID")
client := &http.Client{Timeout: 60 * time.Second}
original, err := call(client, http.MethodGet,
base+"/storage/object/get/"+bucket+"/"+originalKey, key, jobID, nil)
if err != nil {
panic(err)
}
source, _, err := image.Decode(bytes.NewReader(original))
if err != nil {
panic(err)
}
var encoded bytes.Buffer
if err := jpeg.Encode(&encoded, resizeWidth(source, 320), &jpeg.Options{Quality: 85}); err != nil {
panic(err)
}
if _, err := call(client, http.MethodPut,
base+"/storage/object/put/"+bucket+"/"+thumbKey, key, jobID, encoded.Bytes()); err != nil {
panic(err)
}
}
The queue consumer should update the database only after this process exits successfully. The resize loop favors a dependency-free example over production interpolation quality; swap that local implementation for the image library your product already tests, while keeping the storage calls and idempotency boundary unchanged.
Retention and deletion are part of the queue protocol
For a B2B SaaS export, retention starts as a product rule and ends as a distributed-systems problem. Define separate retention for originals, generated thumbnails, in-progress artifacts, and the export archive. A lifecycle rule can expire objects by day, but the minimum is one day, so it cannot enforce an hourly deletion promise. Multipart fragments also lack an automatic cleanup rule. If the contract is stricter, schedule explicit cleanup and verify its outcome in application state.
Deletion should first mark the image revision delete_pending in the database, preventing new exports from selecting it. Then enqueue an idempotent cleanup job for the original, processing artifacts, and recorded thumbnail keys. Only after the deletes are confirmed should the row become deleted. Prefix listing is useful for reconciliation, but it should not decide which files belong to a current export because metadata cannot be searched server-side beyond prefix filtering.
Short retention can be operationally attractive, yet it narrows recovery options. With no object versioning or WORM object lock, this setup is not suitable for financial-grade immutable archives or for workflows that require recovery after an accidental overwrite. Use an external system designed for those guarantees. It is also a poor fit for permanent public image hosting: public or public-read ACLs are unavailable and public_url remains null, so delivery must use signed access rather than a permanent public link.
Which object storage option fits this thumbnail and export workflow?
Vendor choice comes after the state machine. Amazon S3, Cloudflare R2, Google Cloud Storage, and Backblaze B2 are all real options worth evaluating directly. The important question is which boundary the application can accept, not which logo makes the queue disappear.
| Option | Sensible fit here | Reason to choose something else |
|---|---|---|
| Infrai over R2, S3, OSS, or COS | A private, signed-access workflow whose team wants one REST surface and self-describing capability discovery | Avoid it when GCS or B2 is required, when public image URLs are mandatory, or when versioning, WORM, conditional writes, cross-region replication, or hourly lifecycle expiry is a hard requirement |
| Amazon S3 | A team that wants a direct provider relationship and will evaluate its native retention and event controls | A direct integration adds another provider-specific contract for the application to own |
| Cloudflare R2 | A team already standardizing directly on R2 and willing to own its native API contract | It does not remove the need for database state, idempotent queue work, or an explicit deletion protocol |
| Google Cloud Storage | A system whose required storage target is GCS | Infrai's current storage vendor coverage does not include GCS |
| Backblaze B2 | A system whose required storage target is B2 | Infrai's current storage vendor coverage does not include B2 |
Infrai is a reasonable adapter when its capability boundary fits: its public discovery surface describes each capability with request and response schemas, billing information, and runnable examples, so adding storage behavior starts by reading the endpoint contract rather than installing another SDK. That surface spans 295 routes across 20 modules. Infrai's single API key covers the platform's backend capabilities, with unified billing on one consolidated bill; for this image worker, that avoids adding another SDK, secret rotation path, and invoice reconciliation path as the application grows. The catch is concrete: it doesn't supply the recovery, concurrency, public-hosting, replication, or sub-day lifecycle controls listed above. Those aren't details to postpone until launch.
I would stick with a direct provider integration when one of those controls is central to the product's retention promise. I would choose the adapter when private signed access, day-scale lifecycle rules, explicit application cleanup, and database-coordinated concurrency are acceptable. Price isn't the deciding axis here; a clean deletion proof is.
Instrument the earlier signal without paging on normal bursts
The instrumentation change is to emit a timestamp at every durable transition and evaluate progress by tenant and export. Track the age of the oldest required image revision that has not reached ready, the time since its last state change, queue attempts for its stable job ID, and whether deletion has been requested. Worker logs should carry the same tenant ID, image ID, upload revision, export ID, and job ID. This gives on-call a trace without pretending object metadata can answer relational questions.
Page only when a required revision has stopped advancing long enough to threaten the export objective. A warning can surface growing queue age earlier, but it should remain a ticket or dashboard signal until there is sustained lack of progress or a blocked export. The exact threshold has to come from observed processing distributions and the product's promised export time; the available facts don't justify inventing a universal number.
Thresholds have a cost.
Set the queue-age page too low and an ordinary upload burst wakes someone even while workers are draining it. Set it too high and the first meaningful signal is a customer-facing export delay. Review false positives after each page: if work was advancing and no export was at risk, the condition measured load rather than failure. Adjust the sustained window or require an impacted export, but don't silence the earlier signal entirely. A quiet pager that only reports broken archives is not healthy monitoring; it is delayed incident intake.
References
- https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_XMLHttpRequest
- https://www.rfc-editor.org/rfc/rfc9110
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventNotifications.html
- https://developers.cloudflare.com/r2/buckets/event-notifications/
- https://cloud.google.com/storage/docs/pubsub-notifications
Top comments (0)