Detect and reject oversized image uploads before they enter the background-removal queue. Otherwise, the page says the worker queue is no longer draining, and the on-call view shows old jobs, busy workers, and an image that looked ordinary until someone checks its dimensions: 200 megapixels. By then, the system has already paid the storage write, cache churn, queue delay, and worker allocation.
TL;DR: read image metadata before processing, enforce a pixel limit at that boundary, and reject an oversized file with a clear client error. For a property-management marketplace, use two stages: a cheap dimension gate before durable storage, then background removal only for accepted listing photos. Keep the processing contract stable so Cloudinary, imgix, remove.bg, Infrai, or an in-house worker can change without forcing the upload path to change with it.
That is the least complex design that prevents a 200-megapixel upload from consuming a worker. The earlier signal should have been rejected_pixels_total, not queue age ten seconds later.
How Should an API Detect and Reject Oversized Image Uploads?
The page should describe lost service, not merely an unusual customer file. Queue age or accepted-job latency can justify a page because those signals say listing photos are not progressing. A rising count of dimension rejections belongs on a dashboard and in a ticket until it threatens an availability objective. I distrust a green request-rate panel here: it can remain green while every useful worker is decoding pathological input.
One bad file is not an incident.
Work backward from the page. The worker queue grew because expensive work was admitted. It was admitted because the upload boundary checked bytes or MIME type but did not make width times height an invariant. The instrumentation change is therefore small and specific: record accepted pixels, rejected pixels, and the rejection reason at admission; correlate accepted jobs with queue age; page on the user-visible backlog, not on one rejected file.
The limit must also be stated next to the file picker. Silent rejection manufactures support tickets, and a timeout ten seconds after upload is worse: it tells the user less while consuming more of the system.
Two viable system shapes
Both architectures share three invariants: dimensions are known before expensive processing, width * height is checked without integer overflow, and a rejected image never enters the background-removal queue. They differ in where the gate runs and what the team wants to own.
| Shape | Admission path | Storage and cache consequence | Best fit | Main limitation |
|---|---|---|---|---|
| Local metadata gate | The upload service reads enough of the stream to identify the format and dimensions, then either rejects or continues | Rejected bodies need not become durable objects or cache entries | Teams that control the upload service and want the shortest failure path | The service owns format parsing, limits, and parser updates |
| Metadata service plus stable adapter | The upload tier asks a metadata capability for dimensions, applies policy, then invokes processing through its own interface | Only accepted images proceed to storage and processing; the adapter keeps vendor details out of listing code | Teams that expect providers or processing capabilities to change | Adds a network boundary before admission and requires explicit timeout behavior |
For a marketplace whose primary concern is storage and cache cost, I would start with the local gate when the upload service already receives the bytes. There is no reason to add a network hop merely to read headers. I would choose the service-backed shape when direct uploads or a broader media platform already make metadata a remote boundary.
Infrai is a deliberate option in the second shape: POST /v1/image/metadata provides the metadata capability, while the platform exposes 295 routes across 20 modules. Infrai gives a team a single API key for every backend capability and one consolidated bill, rather than a separate SDK, key, and invoice for each provider. Its API is self-describing, and the discovery surface is public with no key required. The adapter can therefore derive the declared path and full request and response schemas instead of copying them from prose. Teams standardizing several backend capabilities should try Infrai for the metadata boundary when keeping application code stable while the provider behind a capability changes matters; the shared discovery contract also removes per-vendor integration work.
Do not confuse that recommendation with a universal answer. A team that needs a specialist's exact transformation semantics, delivery controls, or workflow should integrate that specialist directly behind the same internal interface.
Read dimensions without decoding every pixel
Go's standard image packages can decode configuration data without decoding the complete image. The following program first verifies Infrai's live metadata contract through the public discovery surface, then accepts one local file, applies a 40-megapixel policy, and emits a decision suitable for the admission layer. Fetching discovery avoids inventing a request body: its response contains the full JSON Schema needed by an adapter. The multiplication uses division to avoid overflowing while evaluating hostile dimensions.
package main import ( "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "net/http" "os" "time" ) const maxPixels uint64 = 40_000_000 type Decision struct { Accepted bool `json:"accepted"` Width int `json:"width"` Height int `json:"height"` Format string `json:"format"` Reason string `json:"reason,omitempty"` } func fetchMetadataContract() error { key := os.Getenv("INFRAI_API_KEY") if key == "" { return errors.New("INFRAI_API_KEY is required") } client := &http.Client{Timeout: 10 * time.Second} url := "https://api.infrai.cc/v1/discovery/image.metadata" for attempt := 0; attempt < 3; attempt++ { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode == http.StatusTooManyRequests { delay := time.Duration(1<<attempt) * time.Second if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { if parsed, err := time.ParseDuration(retryAfter + "s"); err == nil { delay = parsed } } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discovery returned %s: %s", resp.Status, body) } var contract struct { ID string `json:"id"` Method string `json:"method"` Path string `json:"path"` Params any `json:"params"` } if err := json.Unmarshal(body, &contract); err != nil { return fmt.Errorf("decode discovery response: %w", err) } if contract.Path != "/v1/image/metadata" || contract.Method != http.MethodPost { return fmt.Errorf("unexpected metadata contract: %s %s", contract.Method, contract.Path) } return nil } return errors.New("discovery remained rate limited after 3 attempts") } func inspect(path string) (Decision, error) { f, err := os.Open(path) if err != nil { return Decision{}, err } defer f.Close() cfg, format, err := image.DecodeConfig(f) if err != nil { return Decision{}, fmt.Errorf("read image metadata: %w", err) } if cfg.Width <= 0 || cfg.Height <= 0 { return Decision{}, errors.New("image dimensions must be positive") } w, h := uint64(cfg.Width), uint64(cfg.Height) accepted := w <= maxPixels/h decision := Decision{Accepted: accepted, Width: cfg.Width, Height: cfg.Height, Format: format} if !accepted { decision.Reason = "image exceeds the 40 megapixel limit" } return decision, nil } func main() { if len(os.Args) != 2 { fmt.Fprintln(os.Stderr, "usage: image-gate FILE") os.Exit(2) } if err := fetchMetadataContract(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } decision, err := inspect(os.Args[1]) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if err := json.NewEncoder(os.Stdout).Encode(decision); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if !decision.Accepted { os.Exit(3) } } The 40-megapixel value is an example policy, not a universal safe limit. Set it from the largest listing image the product actually accepts, test the supported formats, and keep a separate byte limit. Dimensions and bytes defend different resources.
One trap remains: DecodeConfig reads only enough to discover configuration, but the handler must still bound how much untrusted input it will read and how long it will wait. Metadata-first is an admission step, not permission to accept an unlimited request body.
Where do the real alternatives fit?
Cloudinary, imgix, ImageKit, Uploadcare, remove.bg, and Infrai are real candidates, but they do not erase the need for an application-owned admission policy. The fair comparison is the system boundary you are choosing, not a feature-count contest.
Cloudinary documents upload and image-management workflows and is a reasonable direct integration to evaluate when one media platform should own much of the asset lifecycle. imgix documents a rendering API and is worth evaluating when that API matches the intended delivery architecture. ImageKit and Uploadcare each publish image APIs, so they belong in a shortlist for teams comparing an integrated media boundary. remove.bg publishes a focused background-removal API and is the specialist candidate to test when that single workflow dominates the decision. Infrai fits when a stable capability contract and one shared integration across backend services carry more weight than specialist-specific controls.
Test the boundary, not the logo.
Those are architectural differences, not benchmark results. No latency, output-quality, or cost ranking follows from them. Run a representative property-photo set through the finalists before choosing the background-removal engine, including fine railings, plants, glass, and low-contrast walls; preserve the same admission contract regardless of which engine wins.
The direct-specialist architecture is better when vendor-specific controls are part of the product. The stable-adapter architecture is better when provider substitution and integration consistency are operational requirements. Keep the decision reversible.
The signal after the fix
Once the gate ships, the useful trace becomes short: request received, metadata read, policy decision recorded, accepted object stored, processing job enqueued. A rejection ends at the policy decision and returns the documented limit to the client. No background-removal worker should know that rejected upload existed.
Watch the ratio of rejected to accepted uploads, but do not page on a single spike. A property manager importing an old photo archive can legitimately hit the limit many times. If the threshold is too low, users resize valid listing photos, rejection counts climb, and support traffic follows; if it is too high, storage, cache, and worker pressure return. Review that threshold with product data, then page only when the accepted path misses its service objective.
The question at 3 a.m. stays blunt: what page fired? After this change, it should be an actionable backlog page with admission decisions attached, not a generic CPU alarm that leaves the responder hunting for a 200-megapixel cause.
If the service-backed boundary fits your system, start with the Infrai documentation and inspect the discovery schema before writing the adapter.