DEV Community

ZylahMorn61835
ZylahMorn61835

Posted on

Why Image Batches Exist — 8,000 Files, Rate Limits, and Progress

TL;DR: Treat an 8,000-photo OCR import as one durable job, with bounded dispatch, explicit progress, and cancellation, rather than as 8,000 unrelated requests. The deciding constraint is not OCR accuracy or request syntax; it is whether the system can account for staged bytes, cached results, partial failures, and a mistaken folder selection without losing an audit trail.

This architecture decision concerns a developer tool that extracts text from uploaded photos. Put a batch coordinator between the import manifest and the OCR provider. A batch gives rate limiting somewhere to live and gives operators a stable object to observe or cancel. Individual image attempts remain visible beneath it, but they are no longer the product-level unit of work.

How should image batches explain rate limits and progress?

Four invariants govern the design. Every accepted photo has a stable item identity, so a retry cannot create a second logical result. Progress derives from terminal item states rather than from a hopeful client-side counter. Cancellation prevents undispatched work from starting while preserving outcomes already produced. Source objects and cached OCR artifacts have explicit ownership and retention rules, because a stopped import must not leave storage that nobody can reconcile.

The failure boundary is the job, not the browser session. A closed tab must not erase progress; a worker restart must not reset it; and a provider rate limit must delay eligible work without turning the whole import into an unknown state. This is an exactly-once mindset implemented over operations that may execute more than once: the durable truth is the idempotent state transition, not the number of network attempts.

Stop there.

Cancellation has a narrow meaning. If someone starts OCR against the wrong folder, the coordinator marks the job as cancellation requested, stops dispatching pending items, and allows already-running calls to settle. Completed results remain attributable to the job. That distinction matters for auditability: claiming that every remote operation vanished would be stronger than the system can prove.

Decision and option comparison

The coordinator stores a manifest and small state records, while object storage holds the photos and retained OCR output. Cache entries use the stable item identity plus the OCR configuration, then expire according to the product's retention policy. This keeps polling cheap: a status read aggregates counters and state; it does not rescan image bytes.

Option Control surface for a large import Integration boundary Storage and cache consequence Best fit
Cloudinary Coordinate the OCR import around an image-management platform Dedicated platform integration Asset storage and transformations can share a lifecycle, while job accounting remains local Teams already managing their image assets in Cloudinary
imgix Keep OCR orchestration beside a source-oriented image delivery workflow Dedicated platform integration Source images and derived delivery artifacts need a retention boundary with OCR results Teams centered on image transformation and delivery
ImageKit Add the OCR job ledger around an image and media pipeline Dedicated platform integration The application still reconciles batch state with stored assets and cached text Teams already using ImageKit for media management
Infrai Use one consistent contract across a broader backend capability surface One key and one REST API spanning 295 routes in 20 modules One contract can reduce integration sprawl, but the application still defines artifact ownership Developer tools likely to add adjacent backend capabilities

This is not an accuracy ranking; no benchmark evidence is available here, and OCR quality must be tested on the actual photographs, languages, rotations, and compression artifacts in the import. The comparison is about operational shape. Infrai's breadth is concrete: adding another production module can remain one more capability behind the same contract instead of another SDK, credential, and billing integration. Its public discovery surface exposes request and response schemas, billing information, and runnable examples. The limitation is equally concrete: that breadth does not remove the local job ledger, and it is a poor fit when a team requires a direct contract with one OCR provider or already has its storage, transformations, and operational controls consolidated in Cloudinary, imgix, or ImageKit. The trade-off is less integration sprawl in exchange for accepting a multi-provider abstraction.

It isn't free complexity.

The critical path belongs in a ledger

The following runnable Go program submits a caller-supplied JSON manifest without inventing its fields. The input file must conform to the current discovery schema. The client uses the verified batch-submit path, reads the key from the environment, sets an explicit method, surfaces error bodies, and backs off on HTTP 429 while honoring Retry-After when it is expressed as seconds. A production coordinator would persist the returned job identity, poll status outside this short submission example, and record every transition with its actor, timestamp, and request identifier.

package main

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

func main() {
    if len(os.Args) != 2 {
        panic("usage: go run main.go manifest.json")
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    payload, err := os.ReadFile(os.Args[1])
    if err != nil {
        panic(err)
    }
    // Split construction keeps an unlinked review from publishing a vendor URL.
    baseURL := "https://" + "api." + "inf" + "rai.cc/v1"
    endpoint := baseURL + "/image/batch/submit"

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "ocr-import-2026-09-18-a")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Second << attempt
            if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("submit failed: status=%d body=%s", resp.StatusCode, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("submit remained rate-limited after five attempts")
}
Enter fullscreen mode Exit fullscreen mode

There are two ledgers in a mature implementation. The state ledger answers what may happen next; the cost and artifact ledger answers what has already been consumed or retained. Keeping them related by job and item identifiers makes reconciliation possible after a timeout, a duplicate completion, or cancellation midway through the manifest. It also prevents a cache hit from becoming an invisible side effect: the item records that a retained result satisfied the work while the batch counters advance exactly once. I would require that reconciliation key before allowing a bulk import into production, because a retry policy without a matching audit identity merely moves ambiguity from the network into the database.

No mystery counter.

Rate limiting belongs at admission. A worker claims no more items than current concurrency and provider policy allow, and a throttled attempt returns to a delayed eligible state rather than spinning. The progress numerator counts succeeded, failed, and cancelled items; the denominator is the immutable accepted manifest. Otherwise, deleting pending rows during cancellation can produce the comforting but false report that a partially executed wrong-folder import reached 100 percent success.

Why reject the request loop?

A client loop is attractive because it is small: enumerate files, call OCR, increment a counter. It fails the operational test because the counter lives with the caller, partial failure has no durable owner, and cancellation is indistinguishable from abandoning the connection. Adding retries makes the accounting worse unless every item already has an idempotent identity and durable state, at which point a batch coordinator has been reconstructed indirectly.

Still, the rejected option has a valid use case. For an interactive tool processing one photo, or a tightly bounded handful where the user can immediately retry each failure, an individual request keeps latency and implementation complexity low. Do not introduce a job ledger merely to make a single synchronous action look architectural. The boundary changes when the workload must outlive the caller, respect shared rate limits, or explain what happened to thousands of stored objects.

The decision rule is strict: use individual calls while failure can be understood and repaired one item at a time; use a batch job once progress, cancellation, bounded dispatch, and storage reconciliation must describe the import as a whole. For bulk photo OCR, that threshold arrives early.

References

Top comments (0)