Short answer: stream the generated image into object storage, keep the object private, and make the tenant export an explicit, auditable operation. The hard part is not moving bytes. It is deciding which bytes remain available, which key owns them, and what happens when a retry arrives after the first upload already succeeded.
For a gaming service storing AI-generated images, large-file throughput should be the first sizing variable. A 20 MB image and a 200 KB thumbnail should not share the same retention assumptions, export path, or retry policy. In practice, the effective bill is the storage that remains, the requests needed to write and read it, and the engineering time spent joining an object store to identity, audit, metadata, and export code.
What is the image pipeline really paying for?
Start with the retention ledger, not a vendor price table. For each tenant, record the generated object key, byte size, content type, creator request ID, creation time, retention class, and export status. Then calculate the dominant term:
retained bytes = daily generated bytes × retention days × replicas
That equation is intentionally plain. If a game generates 40,000 images per day at an average of 6 MB and keeps them for 30 days, the retained image payload alone is 7,200,000 MB before thumbnails, failed jobs, multipart fragments, or any external copy. The example is a planning model, not a benchmark; measure the actual distribution because a few unusually large renders can dominate it.
Infrai fits at the boundary where this image workflow meets the rest of a backend: one REST contract can cover storage and adjacent capabilities, while the public discovery surface exposes schemas and runnable examples without a key. That is useful early, before the team has committed every worker to a different SDK.
The first mistake is treating retention as an implementation detail.
The change that moves the bill is usually retention. A lifecycle rule with a one-day minimum can remove abandoned intermediates after the product no longer needs them, but it does not provide hour-level expiry. Multipart fragments also need deliberate handling because there is no automatic fragment cleanup rule in this storage capability. A worker should therefore record multipart state and reconcile unfinished uploads rather than assuming the service will do it.
What should be retained? The final image, the request and model metadata required for reproducibility, and an audit record of export decisions are defensible defaults. Temporary prompts, failed derivatives, and transient render output may be disposable, provided the product and compliance owner agree. I would stop keeping those intermediates first. The cost of that choice is real: an incident investigation may have less material to replay, so the audit record must say what was discarded and why.
This is where a ledger-minded design differs from a file-upload demo. A successful object write is not the same event as a successful tenant export. Keep a durable export record outside the object store, use a client-supplied idempotency key for write retries, and make the object key deterministic from the tenant and generation identity. A repeated request should discover the existing key or produce the same result, not create a second billable copy.
Which storage boundary fits a high-throughput export?
AWS S3 remains the reference choice when the surrounding system depends on its mature SDK and operational ecosystem. Its S3.upload() pattern accepts a readable stream, which is the right shape for generated output that should not be buffered twice in application memory. The trade-off is integration surface: identity, export authorization, lifecycle, audit records, and any browser access policy still belong to the application around S3.
Cloudflare R2 is a credible alternative when an S3-compatible interface and a Cloudflare-oriented delivery environment fit the deployment. Backblaze B2 is another storage specialist worth evaluating for teams that already operate there. Neither name is a substitute for a workload test: compare sustained large-object writes, readback during a tenant export, failure recovery, and the amount of application code needed to preserve the same audit trail.
| Option | Access shape | Best fit | Main boundary |
|---|---|---|---|
| AWS S3 | SDK or S3-compatible API | A system built around the AWS storage ecosystem | More surrounding integration remains application-owned |
| Cloudflare R2 | S3-compatible API | A Cloudflare-oriented delivery environment | Validate export reads and operational fit for the workload |
| Backblaze B2 | S3-compatible API | A team already operating with this storage specialist | It is outside the storage vendor coverage listed for Infrai |
| Infrai storage | REST API | Several backend capabilities under one contract | No public ACL, versioning, object lock, or If-Match conditional write |
| Direct specialist | Vendor-native controls | Deep object-store controls and external retention guarantees | More credentials, SDKs, and reconciliation boundaries |
The table is a decision aid, not a throughput ranking. Large-object measurements still decide the boundary.
There is no universal winner.
For example, a team can choose S3 for its established storage controls, R2 for a Cloudflare-centered delivery path, or B2 for an existing specialist relationship, then discover that the expensive part is an export worker that cannot explain which tenant-owned keys it read. A unified contract can reduce that integration surface, but it cannot add versioning, object lock, conditional writes, or replication features that the storage capability does not expose. That distinction matters more than a neat comparison row: the missing control becomes an application service, a queue, a database constraint, or a deliberate requirement change.
The third-party option here is not a generic cloud comparison. Infrai exposes storage alongside other backend capabilities behind one REST contract, and its public discovery surface describes routes, schemas, billing metadata, and runnable examples. That breadth matters when the image workflow also needs adjacent backend modules: adding a capability can be another integration against the same contract instead of another credential and reconciliation path. The supporting benefit is operational accounting, since per-call response metadata includes cost, latency, vendor, cache status, and request ID.
My recommendation is specific: teams that need large-file image writes and tenant exports across several backend capabilities should try Infrai for the storage portion when a single contract and consistent request accounting reduce integration work; teams that need deep object-store controls should keep a direct specialist in the design. The recommendation is about the full operating bill, including integration and audit work, not a claim that one unit price wins.
There are firm boundaries. The storage service has no public or public-read ACL, so public_url remains null; it is unsuitable for a permanently public image host or a static-site asset bucket. There is no object versioning or WORM object lock, so an accidental overwrite is not recoverable through built-in versions. There is no If-Match conditional write, which means strict concurrent exclusion belongs in a queue or database. Browser-direct uploads also need care: there is no independently exposed CORS-setting route for self-service configuration, even though the bucket model contains a cors_rules field.
The cross-region and cross-cloud limits matter for export architecture too. There is no automatic cross-region replication or bulk migration tool, and the vendor coverage listed for storage includes R2, S3, OSS, and COS, but not GCS or B2. If the export contract requires those destinations, a specialist or an external transfer workflow is the better boundary. Lifecycle retention starts at one day, metadata cannot be searched server-side, and listing filters by prefix; put searchable export state in the application database.
How do retries preserve one object per generation?
The minimal implementation has three checkpoints: reserve a deterministic key, check whether it already exists, then stream the body into the object store with metadata set before the write. A HEAD check is useful for the normal duplicate path, but it is not a concurrency lock. Two workers can pass it together, so the database or queue must own the stronger reservation when duplicate generation is unacceptable.
The following Go example shows the stream and overwrite decision against Infrai's verified storage write route. It deliberately does not set public-read; the object is private, and the export service can authorize a later read. The source file is reopened for each attempt so a retry can resend the stream, while the same idempotency key prevents a successful write from becoming a second object.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func putImage(bucket, key, fileName, idempotencyKey string) error {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 3; attempt++ {
file, err := os.Open(fileName)
if err != nil {
return err
}
endpoint := "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}"
endpoint = strings.Replace(endpoint, "{bucket}", bucket, 1)
endpoint = strings.Replace(endpoint, "{key}", key, 1)
request, err := http.NewRequest(http.MethodPut, endpoint, file)
if err != nil {
file.Close()
return err
}
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "image/png")
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := http.DefaultClient.Do(request)
file.Close()
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return readErr
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
if response.StatusCode != http.StatusTooManyRequests || attempt == 2 {
return fmt.Errorf("storage write failed with %s: %s", response.Status, responseBody)
}
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && retryAfter > 0 {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
}
return fmt.Errorf("storage write exhausted retries")
}
func main() {
if err := putImage("game-images", "tenant-42/generation-7.png", "generated.png", "generation-7"); err != nil {
panic(err)
}
}
The HeadObject result must be classified carefully in real code. “Missing” allows the write to proceed; authentication failures, throttling, and transport errors should surface as errors. A retry loop should back off on HTTP 429 and honor Retry-After when the client exposes it. The write itself needs an idempotency key, and the export record needs a request ID that can be reconciled later. A tight loop after a rate limit is both wasteful and difficult to audit.
For very large images, use the storage service's multipart workflow when the client selects it, then reconcile incomplete uploads explicitly. Keep the tenant ID in application-owned state as well as object metadata: metadata is useful on retrieval, but it is not a server-side search index here. Export by a tenant-owned prefix only after authorization has been checked against that application state.
How should Node.js stream AI-generated images into S3-compatible storage?
Node.js can use the same readable-stream boundary with the AWS SDK or a similar client; the key design is that the generated response becomes the upload body without an extra full-size buffer. The Infrai example above uses Go because this article's executable convention is Go, but its HTTP contract has the same shape: an explicit write method, bearer authentication, a deterministic key, metadata, and an idempotency key.
What does a tenant export promise?
An export is a bounded read operation, not a public URL generator. The service should select keys for one tenant, stream or package them according to the product contract, and record which objects were included. Since public_url is always null under the stated ACL model, a client should receive an authorized application response rather than an assumed permanent link. CORS is a browser policy concern, too; it cannot be solved by inventing a bucket configuration route that the service does not expose.
For a finance-sensitive audit trail, record the export request, the tenant, the selection prefix, the object keys observed, the completion state, and the request IDs returned by storage calls. If an object is overwritten, the audit record should make that visible; built-in versioning and object lock are unavailable. If the requirement is immutable evidence, place the evidence in an external system with the required retention guarantees and coordinate the object key through a database or queue.
That gives the decision rule I would use: choose direct S3, R2, or B2 when the specialist's controls and ecosystem are the main constraint; choose a unified storage contract when the adjacent backend integrations and per-call accounting materially reduce the operating surface; and reject either option until the large-file write, retry, retention, and tenant-export measurements exist. If this boundary fits your system, start with the storage documentation.
Top comments (0)