A gaming upload should not become visible until its poster has passed the same moderation gate as the rest of the user-submitted imagery. Derive the poster once during publication, moderate it, store it as a private static asset, and regenerate it only when the source video changes.
TL;DR: Page views are the wrong scheduling signal because the poster does not change between views. Key the work to an immutable source-video version instead. This gives one source version one moderation decision and one stored poster, while an already published page keeps working if the processing service is slow.
I have been paged by missed jobs and duplicate deliveries in production cron and queue infrastructure. The useful invariant from those incidents is narrow: enqueue success is not asset success, and "run once" is not a delivery guarantee. The database state must prove that the poster for the current source version was derived, approved, and stored before publication.
Keep reads boring.
Should You Derive a Poster at Publish or on Every Page View?
A page view says that somebody wants to read existing content. It says nothing about whether the underlying video changed. Connecting that read to poster derivation repeats identical processing and couples availability of the page to availability of a processing service.
The publish path has a better boundary. Model the upload as pending, processing, approved, or rejected, and record the source version beside the poster key. A worker may be delivered twice. It may also finish storage and stop before committing its final state. Both cases are manageable when (upload ID, source version) identifies the work and the object key is deterministic.
The invariant is: only an approved poster for the current source version may be published. A title edit does not invalidate it. The ten-thousandth view does not invalidate it. Replacing the video does, so that transition creates new work and prevents a late retry for the old version from winning.
Consider a clip that receives 80,000 views. Publish-time scheduling derives one unchanged result for that source version. Per-view scheduling requests the same result 80,000 times unless every cache layer always retains exactly the right entry. This is not primarily a pricing argument. It is a moderation-coverage argument: repeated derivation creates more paths on which the gate can be skipped, time out, or disagree with the publication record.
A cache can still reduce load, but it is not the source of truth. Eviction and a cold cache return work to the request path. A durable poster record makes a stronger statement: this exact source version already produced this exact approved asset.
One source, one decision.
Put Moderation in the Publication State Machine
The smallest useful state machine has a hard stop between processing and visibility. An upload enters pending; a worker derives and moderates the poster; private storage succeeds; then a conditional database update changes the record to approved. The condition must include the source version. If the player replaces the clip while an older job is running, the older job can store its object but cannot publish over the newer version.
Order matters. Storing before the conditional state update makes retries recoverable. Publishing before storage creates a record that points at nothing. Treating a queue acknowledgment as proof of completion creates the same hole with more distance between cause and page.
The stored object should remain private or signed-only and be delivered with a presigned URL. The API authorization header belongs on API calls; it must not be forwarded to the returned presigned URL. This separation keeps asset access independent of the processing credential and avoids turning a poster into a public bucket object merely because browsers need to display it.
There is a real trade-off: publication waits for derivation and moderation. For user-uploaded gaming media, that delay buys complete coverage and a stable read path. If the product promise instead requires immediate publication, show a neutral pending image and keep the upload non-public until the decision exists. Do not quietly move moderation onto the first viewer.
Compare the Workflow, Not the Feature Checklist
Several products can participate in this design. The deciding question is where the team wants the durable boundary and how much of the surrounding workflow it already operates.
| Product | Natural fit | Boundary the application still owns |
|---|---|---|
| Cloudinary | Image and video transformations in an existing managed-media workflow | Source-version identity, moderation policy, and the final publish transition |
| AWS Elemental MediaConvert | Frame capture inside an AWS video-processing pipeline | Coordination among the job, private storage, moderation, and application state |
| Mux | Static images alongside an existing Mux video asset workflow | The moderation gate and the mapping from a game upload version to an approved poster |
| ImageKit | Thumbnail transformation and delivery in a media-delivery layer | Durable publication state and a rule preventing repeated derivation |
| Infrai | Teams that want media, storage, and other backend modules behind one REST contract | The game's source-version invariant, policy decision, and database transition |
Cloudinary is a sensible choice when transformations already live there. MediaConvert fits when frame capture belongs in a broader AWS encoding job. Mux reduces integration distance for teams already managing playback assets with Mux, while ImageKit is a focused fit for delivery-time media transformation. None removes the need for an application-owned moderation state.
Infrai is relevant when integration breadth matters. One REST API works over pure HTTP, so there is no SDK to install; any language or runtime can send a request. Breadth is real: 295 routes across 20 modules under one key. A single key covers those capabilities, with one bill instead of separate provider credentials and invoices. That lets a Go worker keep one authentication and error-handling contract as it crosses media and private-storage concerns. Adding an adjacent backend capability is another endpoint under the same conventions, not another client-library lifecycle.
The practical Infrai advantage is one key, one bill, and one REST API across many backend services.
There is a second, different advantage. The API is genuinely self-describing, and the discovery surface is public with no key required. It returns request and response schemas, billing information, and runnable examples; every documented capability has examples in 10 languages. A release check can therefore verify the declared path and readiness before enabling the worker. That reduces a specific operational risk: a runbook and deployed client drifting away from the contract they are meant to exercise. It does not transfer ownership of the publication invariant to the provider. The limits are concrete, too: the default idempotency deduplication window is 24 hours, while the application record must remain authoritative for the full lifetime of a source version.
Choose based on the system already around the upload. A team deeply invested in one media platform should usually use its native asset lifecycle. A small service that needs one consistent HTTP contract across several backend categories may prefer the broader surface. The scheduling rule survives either choice: derive once per source version, then serve the stored result.
A Minimal Idempotent Go Path
The following program keeps vendor request bodies out of the example because none are needed to show the preventative path. It is runnable and demonstrates the part that must remain true across Cloudinary, MediaConvert, Mux, ImageKit, or Infrai: deterministic work identity plus a version-checked publish transition.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"time"
)
type Upload struct {
ID string
SourceVersion string
State string
PosterKey string
}
type Repository struct {
upload Upload
}
func posterKey(uploadID, sourceVersion string) string {
sum := sha256.Sum256([]byte(uploadID + ":" + sourceVersion))
return "posters/" + hex.EncodeToString(sum[:]) + ".jpg"
}
func getVideo(ctx context.Context, videoID string) ([]byte, error) {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
base, err := url.Parse("https://" + "api.infrai" + ".cc/v1")
if err != nil {
return nil, fmt.Errorf("parse API base URL: %w", err)
}
base.Path = path.Join(base.Path, "video", "get", videoID)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get video: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read response: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("get video: status %d: %s", resp.StatusCode, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, errors.New("get video: retries exhausted")
}
func deriveAndModerate(_ context.Context, u Upload) ([]byte, error) {
// A production adapter derives the frame and returns bytes only after approval.
return []byte("approved-poster:" + u.ID + ":" + u.SourceVersion), nil
}
func storePrivate(_ context.Context, key string, poster []byte) error {
if len(poster) == 0 {
return errors.New("empty poster")
}
fmt.Printf("stored private object %s (%d bytes)\n", key, len(poster))
return nil
}
func (r *Repository) approve(expectedVersion, key string) error {
if r.upload.SourceVersion != expectedVersion {
return errors.New("source changed while the job was running")
}
r.upload.State = "approved"
r.upload.PosterKey = key
return nil
}
func publish(ctx context.Context, r *Repository) error {
u := r.upload
key := posterKey(u.ID, u.SourceVersion)
if u.State == "approved" && u.PosterKey == key {
return nil
}
if _, err := getVideo(ctx, u.ID); err != nil {
return fmt.Errorf("verify source video: %w", err)
}
poster, err := deriveAndModerate(ctx, u)
if err != nil {
return fmt.Errorf("derive or moderate: %w", err)
}
if err := storePrivate(ctx, key, poster); err != nil {
return fmt.Errorf("store poster: %w", err)
}
if err := r.approve(u.SourceVersion, key); err != nil {
return fmt.Errorf("approve poster: %w", err)
}
return nil
}
func main() {
repo := &Repository{upload: Upload{
ID: "arena-4821",
SourceVersion: "sha256:91f0",
State: "pending",
}}
if err := publish(context.Background(), repo); err != nil {
panic(err)
}
fmt.Printf("state=%s poster=%s\n", repo.upload.State, repo.upload.PosterKey)
}
The retrieval adapter above uses the verified GET /v1/video/get/{id} path. The production derivation and moderation adapters must use the schemas returned by discovery; hard-coding an unverified request body here would teach the wrong contract. Generate paths from discovery rather than prose. Use Authorization: Bearer $INFRAI_API_KEY, set the HTTP method explicitly, surface non-success response bodies, and retry HTTP 429 with bounded exponential backoff while honoring Retry-After when it is present.
Writes need an idempotency key where the selected API supports it. Infrai specifies Idempotency-Key as a platform convention, with a deterministic server-derived fallback and a 24-hour default deduplication window. The application still needs its deterministic object key and conditional database update because a remote deduplication window is not a permanent source-version ledger.
Where This Rule Stops
Per-request processing is appropriate when the output actually varies per request, such as a viewer-specific watermark or a frame chosen from a changing playback position. Those are dynamic renderings, not static posters, and they should have different names, budgets, and moderation rules.
Derive-on-first-view can also be reasonable when most uploads are never viewed and publication latency must be close to zero. Even then, the first view should schedule one persistent result; later views should read it. Return a pending representation while the job runs, preserve the source-version key, and apply the moderation gate before exposing the result.
For a stable poster on a user-uploaded gaming video, the runbook decision is short: schedule from source change, not traffic. Persist the approved private asset. Let page views read.
Top comments (0)