TL;DR
For an AI marketing video app, treat generation, polling, download, and responsive thumbnail creation as one durable job lifecycle. Store the remote job ID before polling, back off between status checks, accept the video only after the download is validated, and create thumbnail variants once per completed asset. The best option is the one that minimizes repeat storage and cache work while preserving a clean recovery path.
That sounds heavier than a single generate() call. It isn't. The extra state is small, and it prevents a page refresh, worker restart, or duplicate upload event from turning into another generation request.
How should marketing video jobs handle generation, polling, and download?
Start with a state machine, not a vendor SDK. I use five local states: queued, generating, downloading, processing, and ready. A sixth state, failed, holds a reason that an operator or retry policy can inspect. The remote system may expose different labels.
Map them once.
The important transition happens before the first poll: persist the local job and its remote ID together. If the process exits after generation starts but before that write, the app has paid for work it can no longer find. If an upload callback is delivered twice, an idempotency key derived from the source asset and generation settings lets the application return the existing local job instead of starting another one. This is less glamorous than model selection. It also has more leverage over cost.
Polling belongs in a worker.
Don't tie it to a browser tab or an open request. The worker records nextPollAt, claims jobs that are due, checks their remote status, and schedules the next attempt with bounded backoff. Add a small random offset so a batch created at the same moment doesn't stay synchronized. Keep an overall deadline as well; endless polling is not a recovery strategy.
There is one subtle boundary: a remote completed state means the output is available, not that the application owns a verified copy. Move the local job to downloading, stream the file into temporary storage, validate the media, and only then promote it to the permanent object key. The media container and codec determine browser compatibility, so extension checks alone are weak. MDN's media format guide is the useful baseline here.
Then create the responsive thumbnails.
Do it once.
A manifest that records the source fingerprint, transformation version, width, height, format, and final object key gives both the application and the cache a stable identity. Reprocessing becomes a deliberate version change rather than an accidental side effect of a request.
The smallest working TypeScript implementation
The interface below deliberately hides vendor vocabulary. It also keeps generation credentials in the worker, away from client code. The exact persistence and media inspection tools are deployment choices; the lifecycle contract is the part worth stabilizing.
type LocalState =
| "queued"
| "generating"
| "downloading"
| "processing"
| "ready"
| "failed";
type RemoteState =
| { kind: "pending" }
| { kind: "complete"; downloadUrl: string }
| { kind: "failed"; reason: string };
type VideoJob = {
id: string;
remoteId: string;
state: LocalState;
attempts: number;
nextPollAt: number;
outputKey?: string;
};
interface Generator {
status(id: string): Promise<RemoteState>;
}
interface JobStore {
get(id: string): Promise<VideoJob>;
save(job: VideoJob): Promise<void>;
}
interface MediaStore {
importAndValidate(
url: string,
temporaryKey: string,
): Promise<{ sourceKey: string }>;
createThumbnails(
sourceKey: string,
widths: readonly number[],
): Promise<string[]>;
}
const pollDelayMs = (attempt: number): number => {
const ceiling = 30_000;
const base = Math.min(1_000 * 2 ** attempt, ceiling);
return base + Math.floor(Math.random() * 500);
};
async function advanceJob(
id: string,
generator: Generator,
jobs: JobStore,
media: MediaStore,
): Promise<void> {
const job = await jobs.get(id);
if (job.state === "ready" || job.state === "failed") return;
const remote = await generator.status(job.remoteId);
if (remote.kind === "pending") {
const attempts = job.attempts + 1;
await jobs.save({
...job,
state: "generating",
attempts,
nextPollAt: Date.now() + pollDelayMs(attempts),
});
return;
}
if (remote.kind === "failed") {
await jobs.save({ ...job, state: "failed" });
return;
}
await jobs.save({ ...job, state: "downloading" });
const imported = await media.importAndValidate(
remote.downloadUrl,
`temporary/${job.id}`,
);
await jobs.save({ ...job, state: "processing" });
await media.createThumbnails(imported.sourceKey, [320, 640, 960]);
await jobs.save({
...job,
state: "ready",
outputKey: imported.sourceKey,
});
}
This is intentionally incomplete at the infrastructure edge. importAndValidate should stream rather than buffer a full video in memory, reject content that fails the application's media policy, and promote a validated object atomically. createThumbnails should be idempotent for a given transformation version. Those are interface requirements, not comments to remember later.
The three widths are example application settings, not universal breakpoints. Measure the rendered slots in the marketing app, then choose variants that avoid large overfetches without creating a pile of rarely used files. I'm not sure where that balance lands for your layout; request logs and cache data resolve it.
Storage and cache cost changed the design
My first sketch for this kind of API is always smaller: generate, wait, download, respond. I discard that sketch once storage and cache cost become the primary decision axis. A synchronous-looking flow hides repeat work, gives the UI ownership of recovery, and encourages thumbnail generation on demand. The code is shorter. The bill and operational surface aren't.
Count bytes through the whole path. There is the generated video entering your storage, temporary bytes during validation, the permanent source, thumbnail outputs, cache fills, cache misses, and any re-download caused by an expired or lost reference. Request count matters, but it can distract from the larger object transfers. I benchmark the actual workflow with representative source duration and dimensions; a tiny test clip says little about production storage behavior. A content-derived source fingerprint prevents identical uploads from producing parallel derivative trees. A transformation version prevents a new crop rule from overwriting old keys while cached responses still refer to them. Put both into the object identity. For example, thumbnails/{sourceFingerprint}/{transformVersion}/640.webp is easier to reason about than a random filename backed by a database lookup. The name is an example, not a requirement.
Cache policy follows mutability. Versioned derivative objects can use long-lived caching because their bytes never change; a job-status response changes often and needs a different policy. Mixing both behind one broad rule creates either stale status or needless thumbnail revalidation.
Keep that boundary visible.
Measure generation starts per accepted upload, polls per completed job, source bytes stored, derivative bytes stored, download bytes, thumbnail cache hit ratio, and time spent in each local state. I care most about the ratios. Ten thousand polls may be fine across ten thousand long jobs and absurd across ten.
One line matters here.
A cache miss is not automatically a defect. A thumbnail requested once may cost more to precompute, store, and invalidate than to create lazily. The decision depends on traffic concentration: precompute the sizes that almost every page uses, and consider lazy generation for unusual editorial crops. If latency on the first view is unacceptable, precompute wins even when storage utilization is lower.
What I would change at scale
The first change is a lease around each state transition. More workers should increase throughput without letting two workers download the same output or write the same derivative set. The lease needs a finite expiry so abandoned work becomes claimable, while the transition itself remains idempotent. A queue can wake workers efficiently, but the database record stays authoritative because queue delivery and business completion are different events. Next, separate generation concurrency from download and thumbnail concurrency. They consume different resources and fail at different boundaries. Generation is mostly remote waiting. Download stresses network and object storage. Thumbnail work consumes local or managed media-processing capacity. One global worker limit makes a burst in any stage block the others.
I would also add a reconciliation pass. It finds jobs whose nextPollAt is overdue, temporary objects older than the import window, ready records missing their manifest, and manifests whose transformation version is no longer active. Reconciliation is not the main path; it is the audit that proves the main path can recover after a worker disappears between two durable writes.
Keep the UI contract plain: return the local job ID, current state, and a retry hint while work is active; return stable asset metadata when it is ready. The browser may poll that local resource, or the server may push updates, but neither choice should change the generation adapter. That's the DX test. A different generator should require one boundary implementation, not edits across components, workers, and storage code.
Trade-offs and the selection rule
This design is not suitable for every video feature. For a live, interactive preview where frames must arrive during generation, a completed-file download boundary is too coarse; choose a streaming pipeline and accept a more complicated session model. For a low-volume internal tool where operators can wait and rerun work manually, leases, reconciliation, and a derivative manifest may cost more engineering time than they save. Start with durable job IDs and idempotent imports, then add the rest when observed load justifies it.
Precomputing every thumbnail is also a poor fit when each customer defines arbitrary aspect ratios. In that case, keep a validated source and use a bounded transformation service with signed, normalized parameters. The catch is cache fragmentation: unconstrained widths and crop values turn nearly identical requests into distinct objects. Normalize the allowed dimensions before they reach the cache key.
For selection, test candidates against the lifecycle rather than comparing feature grids. Can generation be started idempotently? Can a remote ID survive process restarts? Are status categories mappable without leaking provider terms? Does the completed result remain downloadable long enough for a worker to claim it? Can the system stream into your chosen storage? Can you calculate stored and transferred bytes from logs? Does changing a thumbnail transformation produce a new immutable identity?
Reject an option when it forces generation, polling, and downloading into the browser, or when output ownership cannot move into storage you control. Prefer the option whose adapter stays thin under a failure drill and whose storage accounting can be explained from recorded events. Price can break a tie only after those conditions hold; it cannot repair an unrecoverable lifecycle.
The final benchmark is deliberately dull: start a job, stop the worker after each durable transition, restart it, and confirm that exactly one accepted video and one set of expected thumbnails become ready. Then repeat with a duplicate upload event and a cold cache. If the architecture passes those tests and exposes the byte ratios above, the selection is grounded in the actual marketing-video workload.
Top comments (1)
I appreciate how you emphasized the importance of a state machine for managing the video job lifecycle; it really clarifies the flow and helps avoid pitfalls like duplicate uploads. The implementation of polling in a worker context is also a great choice, as it decouples the job management from user interaction, allowing for a more resilient system. One improvement idea could be to leverage a message queue for job status updates, which could further enhance reliability and scalability. If you're exploring additional development on this workflow or need support with the TypeScript implementation, I'd be happy to discuss a paid collaboration. What challenges have you faced with the current architecture?