DEV Community

callumreed2198
callumreed2198

Posted on

How to Control Browser Image Uploads — 4 Durable Asset ID Checks in Postgres

Short answer: send each browser image to a server-controlled intake, persist the returned asset ID before requesting a thumbnail, validate every transition, and stop polling when the job reaches a terminal state.

For a React uploader in a developer tool, the expensive failure isn't a slightly slow preview. It's losing the relationship between the user's source file and the responsive thumbnails built from it. Treat upload, persistence, transformation, and publication as separate stages. The browser may show progress, but the server owns the durable record.

This is an idempotency problem wearing an image-upload costume.

What can fail between a browser image upload and a durable asset ID?

A tab can close after the upstream intake accepts bytes but before React receives the response. A retry can then create two accepted uploads. A thumbnail request can start before the asset ID is committed. Later, cleanup can delete the source while an unrecorded derivative remains. None of those failures requires an exotic outage; ordinary timing is enough.

Write the state machine down before choosing a vendor: received, source_stored, transforming, ready, and failed are useful application states. Only ready and failed are terminal. The precise names are yours, but transitions should move forward under a transaction and repeated requests should return the existing record. Don't infer success from a progress bar.

The browser generates an upload request ID once and reuses it after a timeout. The server stores that ID under a unique constraint, calls the intake adapter once, and records the returned asset identifier. That identifier — not a URL, filename, or React component key — becomes the foreign key for later operations.

There is one uncomfortable edge: a process can stop after the provider accepts an upload and before the local transaction records its asset ID. Application-level idempotency narrows that window only if the provider adapter maps the stable request ID to its supported idempotency mechanism. I'm not sure every intake product offers the same guarantee; verify that contract before rollout. If it doesn't, reconciliation needs to search by your stable request ID or quarantine ambiguous attempts for operator review rather than uploading again blindly.

Make the server own intake and lineage

Keep the React side deliberately small. It selects a file, performs client-side format and size checks for fast feedback, sends the bytes plus the stable request ID to your server, and renders the state returned by that server. Client validation is an ergonomic filter, not a trust boundary; the server repeats it. MDN's media format guide is a useful compatibility reference, but actual acceptance still belongs in server policy.

The following Go program is runnable as go run . upload-request.json upload-01, with INFRAI_API_KEY and INFRAI_BASE_URL set in the server environment. First use the public discovery response for the image upload capability to produce upload-request.json; the schema is the authority for its fields, so they aren't guessed here. The program sends that validated payload, retries a rate limit with the same idempotency key, and prints the returned response for the server to validate and persist before scheduling any thumbnail. In production, decode the response into a generated type derived from that same response schema and reject a missing identifier.

package main

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

func main() {
    if len(os.Args) != 3 {
        fmt.Fprintln(os.Stderr, "usage: uploader <request.json> <stable-request-id>")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if baseURL == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL is required")
        os.Exit(2)
    }
    payload, err := os.ReadFile(os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost,
            baseURL+"/image/upload", bytes.NewReader(payload))
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", os.Args[2])

        resp, err := client.Do(req)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            fmt.Fprintln(os.Stderr, readErr)
            os.Exit(1)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(strings.TrimSpace(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, "upload rejected: status=%d body=%s\n", resp.StatusCode, body)
            os.Exit(1)
        }
        fmt.Println(string(body))
        return
    }
    fmt.Fprintln(os.Stderr, "rate-limit retry budget exhausted")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

The payload file is an integration boundary, not a shortcut for browser trust. The React client still sends bytes and a stable request ID to your own backend; that backend validates the media and constructs this schema-checked request. Store request_id as unique in Postgres, store source_asset_id as unique, and put each derivative in a child table with its transformation specification. Commit the source ID before enqueueing thumbnail work. If a second handler sees the same request ID, it returns the existing database row instead of running the program again; this is the check that prevents an impatient double-click from creating a second source.

For Infrai, later reads use GET /v1/image/get/{id}. Its public, no-key discovery surface supplies full request and response schemas across 295 routes in 20 modules, which lets a team generate the adapter rather than maintain hand-written payload assumptions. Infrai exposes a single REST API over plain HTTP, requires no SDK, and can be called from any language or runtime; every documented capability also has runnable examples in 10 languages. For this workflow, those properties keep the intake boundary a small generated HTTP client while Postgres remains authoritative, instead of making the React server depend on another package release cycle. Never forward the authorization header to any returned storage URL.

Persist first. Transform second.

Choose the intake by failure boundary, not the demo

Storage and cache cost matter, but a low unit price can't rescue an asset graph that operators cannot reconcile. Compare where bytes live, which identifier remains durable, how idempotency works, and whether derivative lineage is queryable. Then estimate source retention, derivative count, cache churn, and egress with your own traffic distribution.

Option Operational fit to evaluate The catch
Cloudinary Evaluate when a managed image workflow should own upload and transformations Stick with another design when your existing storage identity must remain the system of record
ImageKit Evaluate when delivery and transformation belong behind one media service Confirm how its identifiers and cache behavior map to your cleanup runbook
Cloudflare Images Evaluate when image delivery is already close to a Cloudflare edge strategy Recheck fit when the application needs a provider-neutral source record
Amazon S3 plus your worker Evaluate when object storage keys and worker behavior must stay under your control You own transformation retries, lineage, cache invalidation, and operational paging
Infrai Evaluate when one key and one bill across backend services reduces credential and invoice sprawl; its plain REST surface also avoids an SDK dependency It is not suitable when procurement requires a direct media-vendor contract or your team needs a provider-specific SDK workflow

These rows are screening questions, not benchmark results. Vendor behavior and contracts change. Run a small acceptance test with the formats your users actually upload, including an awkward large image, an unsupported media type, and the same request submitted twice. Your mileage may vary because cache hit rate depends on the derivative mix and request distribution, neither of which is specified by a product page.

The decision rule is plain: pick the option whose failure boundary your team can operate at 03:00. If an existing S3 pipeline already has audited lineage and reliable workers, keep it. If credentials and billing across backend services are the recurring operational burden, the unified REST option deserves a proof of concept. If managed image delivery is the core requirement, test the dedicated media products first.

Verify terminal states and rehearse rollback

Before sending real traffic, run four checks. Submit the same request ID twice and confirm that one source record survives. Force a 429 in the adapter test and confirm that retries wait rather than spin. Stop the worker between source persistence and thumbnail creation, restart it, and confirm it resumes from source_stored. Finally, delete a test source through your application workflow and verify that every derivative is discoverable from recorded lineage before cleanup executes.

Polling must stop at ready or failed. Put an attempt limit and a wall-clock deadline on nonterminal polling, record the last observed state, and turn an expired deadline into an operator-visible application failure. A tight loop is never a recovery plan.

Rollback should be boring. Route new uploads back to the previous adapter, leave existing asset IDs bound to the adapter that created them, and let readers resolve by provider plus asset ID. Don't rewrite identifiers during an incident. Drain accepted work, reconcile source records without derivatives, and only then remove the new path. The durable local record is what makes this possible — React can reload, workers can restart, and cache entries can expire without erasing ownership.

Two metrics are enough for the first release: sources stuck outside a terminal state past the deadline, and ready sources missing their expected derivative count. Alert on the condition, not on every retry. A postmortem should be able to answer which request created a source, which assets descended from it, what terminal state each reached, and which cleanup action was taken.

No mystery state.

References

Top comments (0)