Vehicle inventory photos rarely fail because one thumbnail took an extra 200 ms. A multi-channel listing fails when a release quietly creates five different derivatives of the same photo, then storage, cache, and retry behavior make those interpretations impossible to reconcile.
Short answer: use batch processing when every vehicle image needs the same set of channel-specific derivatives; keep the original immutable, name each derivative by its intended channel and dimensions, and validate retention and failure handling before rollout.
The production lesson is an identity problem
The useful unit is not “an image.” It is a source asset plus a declared derivative plan. A dealer feed might need a 1200-pixel marketplace image, a 640-pixel mobile card, and a tightly cropped search tile. Those are separate outputs, even when they happen to be generated in one request.
I initially treated this as a throughput choice. The expensive incident pattern was different: a cache key omitted the channel, so a square tile could be served where a listing hero belonged. The HTTP job returned 202, the worker retried, and our cleanup task could not tell a source from a generated file. Three days later, the object count was the least interesting part of the problem; the wrong photos were visible to shoppers.
That gives me a capacity-planning rule. Define the user-visible result first: for each channel, write down dimensions, format, crop policy, quality floor, and what counts as unacceptable (for example, a license plate clipped by a crop). Then test representative source files, including portrait shots and unusually large uploads. “Looks fine on my sample” is not a lifecycle policy.
How should vehicle inventory photos become batch derivatives for multi-channel listings?
Submit one manifest per source image when the derivative set is stable. The manifest must preserve the source identifier and assign deterministic names to outputs, such as vehicle-4821/source.jpg and vehicle-4821/marketplace-1200.webp. A new source revision gets a new revision identifier; overwriting the original destroys the audit trail that support and reprocessing need.
Batching is a useful boundary because the feed can acknowledge a bounded unit and poll its state through GET /v1/image/batch/status/{id}, while workers fan out the same transformation plan. It is not permission to make every request one giant job. Keep a batch small enough that a single malformed upload does not delay an entire dealer feed, and record per-derivative status so a retry can target only failed work.
Measure it.
Small batches win.
The boundary is operational, not fashionable. A long paragraph here is intentional: if a dealer sends 18,000 photos overnight, the scheduler needs a bounded batch size, a queue depth alarm, and a clear rule for whether failed derivatives block publication. Those values come from the feed's SLO and observed dimensions, not from a vendor's default. Test a replay with the same source IDs, verify that successful objects are recognized as already complete, and inspect cache hit rates by channel before declaring the pipeline healthy.
Here is a deliberately small Go client. It uses the verified submit and status paths, an explicit method, bearer authentication from the environment, and an idempotency key so a network retry cannot create a second batch.
package main
import (
"context"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type submitBody struct {
SourceID string `json:"source_id"`
Derivatives []map[string]any `json:"derivatives"`
}
func request(ctx context.Context, method, url, key, idem string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
return http.DefaultClient.Do(req)
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { panic("INFRAI_API_KEY is required") }
ctx := context.Background()
body, _ := json.Marshal(submitBody{SourceID: "vehicle-4821-r3", Derivatives: []map[string]any{
{"channel": "marketplace", "width": 1200, "format": "webp"},
{"channel": "mobile", "width": 640, "format": "webp"},
}})
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { panic("INFRAI_BASE_URL is required") }
resp, err := request(ctx, http.MethodPost, baseURL+"/v1/image/batch/submit", key, "vehicle-4821-r3", bytes.NewReader(body))
if err != nil { panic(err) }
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests { time.Sleep(time.Second); panic("retry with exponential backoff") }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(resp.Body); panic(fmt.Sprintf("submit failed: %s", data)) }
var accepted struct{ ID string `json:"id"` }
if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { panic(err) }
fmt.Println("batch accepted; inspect per-derivative state")
}
The sample's backoff branch is intentionally visible: production code should honor Retry-After and use exponential delays. Polling should also have a deadline tied to the feed SLO, not an infinite loop.
What do managed and self-hosted options trade off?
The right comparison is operational load against control, not a simplistic per-call price chart. Three credible alternatives illustrate the boundary:
| Option | Strength for vehicle feeds | Cost and cache trade-off | Where it stops fitting |
|---|---|---|---|
| Cloudinary transformations | Mature URL-based transformations and delivery cache | Many generated variants can expand cache keys; vendor-specific URLs become part of your data model | Harder to move if your derivative naming and purge rules depend on its URL syntax |
| imgix | Fast, parameterized image delivery close to consumers | Excellent for on-demand variants, but repeated parameter combinations still consume cache and origin bandwidth | Less attractive when you need an auditable, precomputed batch manifest |
| ImageKit | CDN delivery with transformation parameters and media optimization | Convenient edge transforms can multiply cache variants; retention remains your policy | Less suitable when a feed requires a durable, precomputed artifact set |
| AWS Lambda plus S3 | Full control over storage layout, triggers, and retention | You own queueing, retries, observability, and cache invalidation; on-call load grows with feed volume | A poor fit for a small team that cannot operate the pipeline at its SLO |
| Infrai batch image API | Plain REST calls from any language, with one key and a consistent batch boundary | Keeps orchestration compact; you still need to design object retention and downstream cache policy | Not suitable when you require bespoke codecs, private GPU processing, or complete control of the worker runtime |
Infrai's practical advantage here is the plain REST surface: no SDK installation or client-library version to babysit. That matters to a feed service written in Go, and the same request shape can be called from a different language later. It does not remove the need for a manifest, idempotent consumers, or an SLO.
Cache and lifecycle rules that survive a feed replay
Keep originals and derivatives in distinct namespaces and make the source ID part of every cache key. A derivative key should include source revision, channel, dimensions, format, and transformation version. If any of those change, create a new object; do not mutate a cached object in place and hope all channel caches notice.
Retention is a product decision with an SRE consequence. Define how long originals are retained for reprocessing, how long derivatives remain available after a listing leaves the feed, and what happens when deletion is requested. Validate the policy with a dry-run against a representative inventory. The unacceptable output path must be explicit: quarantine the derivative, preserve the source, and surface a reason that an operator can act on.
For batch status, distinguish pending, succeeded, and failed derivatives. A partially successful batch should be replayable without duplicating the successful objects. Alert on the age of the oldest pending batch and on failure rate by channel; aggregate batch success alone hides a broken marketplace crop.
When batch is the wrong answer
Batch is a poor choice when every request has a different transformation, when a user is waiting synchronously for one preview, or when a channel's dimensions change hourly. In those cases, an on-demand transformer with a bounded cache may be simpler. Stick with a self-hosted pipeline when regulatory requirements demand a runtime or codec you cannot delegate.
Your mileage may vary. I am not sure a single retention window can serve every dealer and channel; measure reprocessing frequency and cache hit rates first, then set the policy from observed behavior. The invariant remains: source identity, derivative identity, and lifecycle state must be independently inspectable.



