
Async Media Generation APIs: When to Block, When to Queue
Image, video, and audio generation take seconds, not milliseconds. If you call the API synchronously, your agent runtime holds a connection open, your UI freezes, and your retry logic becomes a cron job. An async job API is the only architecture that survives production traffic.
Sync vs async: when blocking is fine vs expensive
The first question any developer asks is: can I just call the API and get the image back inline? The honest answer is "it depends on what you're calling from."
Blocking is fine when
- You are a CLI tool that runs unattended. A build script that generates one asset and then exits can wait 5–30 seconds. Nothing else cares.
- You are a one-shot batch job with no parallelism requirements. The job takes as long as it takes.
- The asset is tiny and the model is fast. A 256×256 thumbnail from a small model can return in under a second. Synchronous is fine.
- You have no concurrency concerns. Your runtime is not also serving user requests, and there is no user-facing timeout.
Blocking is expensive when
- You are inside an agent tool call. Most agent runtimes (Claude, Codex CLI, Cursor, Windsurf, Cline) have a tool-call timeout. A 15-second image generation that returns inline can hit the timeout, fail the tool call, and force the agent to retry — burning tokens on every retry.
- You are serving a user-facing request. A web request that blocks on image generation ties up an event loop slot, a worker, and the user's patience. HTTP timeouts (often 10s) will cut off the response before the asset is ready.
- You are orchestrating many assets at once. Generating 10 hero variants for A/B testing synchronously means 10 sequential 15-second calls = 2.5 minutes. Async means 10 parallel 15-second calls = ~15 seconds.
- You need retry semantics. A retry on a synchronous call re-pays the full generation cost. A retry on an idempotent async job returns the existing job ID — no double charge, no duplicate asset.
The rule of thumb: **if the call is part of a user-facing request path or an agent loop, async. If it's a CLI batch job with no parallelism pressure, sync is fine.**
The job lifecycle: submit → poll/webhook → download
An async media generation pipeline has a well-defined lifecycle. Once you've used one, every other one looks the same.
1. Submit a job
curl -X POST https://api.mcpmediaengine.com/api/jobs \
-H "Authorization: Bearer me_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"type": "image",
"inputParams": {
"prompt": "abstract pipeline / queue visualization, neutral tones, no text/faces",
"aspect_ratio": "16:9",
"size": "1024x1024"
},
"webhookUrl": "https://your-app.com/webhooks/mediaengine",
"idempotencyKey": "article-2026-07-08-hero"
}'The response is a job record:
{
"data": {
"mediaJobId": "mjob_abc123...",
"status": "queued",
"type": "image",
"provider": "mediaengine",
"createdAt": "2026-07-08T10:00:00Z"
}
}Key fields: - **`mediaJobId`** — your handle for the rest of the lifecycle. Store this; you will need it to fetch the asset, retry the request, or attach the URL to a blog post. - **`status`** — starts at `queued`, advances to `processing`, then `done` or `failed`. - **`provider`** — always `"mediaengine"` (provider names are rewritten at the API boundary so callers never see upstream implementation details). - **`idempotencyKey`** — the safety net. See §"Idempotency" below.
2. Wait for completion (three options)
**Option A — Poll.** Simplest, no inbound surface needed. A typical cadence is every 3 seconds with a 2-minute timeout:
curl https://api.mcpmediaengine.com/api/jobs/mjob_abc123 \
-H "Authorization: Bearer me_live_your_api_key"The TypeScript SDK ships a helper that handles this for you:
const client = new MediaEngineClient({ apiKey: process.env.MEDIAENGINE_API_KEY }); const asset = await client.waitForJob("mjob_abc123", { pollInterval: 3000, timeout: 120_000, }); ```
**Option B — Webhook.** Pass `webhookUrl` at job creation. When the job completes, the server POSTs a `job.completed` event to your URL with an HMAC-SHA256 signature in the `X-MediaEngine-Signature` header. Verify it with `verifyWebhookSignature` from the SDK before trusting the payload. Webhook URLs must be HTTPS and must not target a private or loopback address — this is enforced server-side to prevent SSRF.
**Option C — Both.** Polling as a backup to a webhook is a common pattern. The webhook gets you the asset quickly under normal conditions; the poll catches the case where the webhook delivery failed (network blip, your service restarted).
3. Download the asset
The `outputUrl` returned with a `done` job is a signed CDN URL valid for 24 hours. For long-lived references (blog posts, social previews, email headers), download the asset to your own storage and serve from there. The `GET /api/jobs/{id}/download` endpoint returns a fresh signed URL on demand if you need to re-fetch.
Idempotency and retries: why job IDs are your idempotency keys
A job-based API gives you retries for free. The mechanism: **idempotency keys**.
Every `POST /api/jobs` accepts an optional `idempotencyKey`. If you retry the same request with the same key, you get the existing job back instead of a duplicate. No double billing. No duplicate asset. No cleanup work.
How to choose idempotency keys
A good idempotency key is **deterministic for the same logical request** and **distinct for different requests**. Some patterns:
- **One hero per article** — `article-{articleId}-hero` (e.g. `article-2026-07-08-hero`)
- **A/B test variants** — `article-{articleId}-variant-{A|B|C}` (e.g. `article-2026-07-08-variant-B`)
- **Social card per post** — `social-{postId}-{platform}` (e.g. `social-post-9001-twitter`)
- **Regenerate on prompt change** — hash of prompt + parameters, e.g. `sha256(prompt + size + model)[0:16]`
The point: if your content pipeline crashes mid-flight and re-runs, the retry should produce the same assets as the original run — same job IDs, same output URLs. Your downstream code that already mapped `articleId → mediaJobId` keeps working.
When retries actually happen
- **The HTTP request times out.** Your side never got the response. The server may or may not have created the job. The retry, with the same idempotency key, returns the same job ID either way.
- **The agent loop retries the tool call.** Agent frameworks often retry tool calls on transient failure. Idempotency keys prevent the retry from costing you a second generation credit.
- **You re-run a batch job after a fix.** Same input → same idempotency keys → same assets.
Cost pattern: credit per call, no idle compute
A job-based pricing model is fundamentally different from "GPU rented by the hour" pricing.
The wrong model
A GPU that costs $1.50/hour sits idle 90% of the time. You pay for the idle compute whether or not you're generating. This is how self-hosted models work, and it's why most teams end up over-provisioning.
The right model
A credit-per-call model charges only for the seconds the model is actually running. No idle compute. No over-provisioning. No paying for capacity you don't use.
For image generation, the typical pattern is:
- **Default Image** — 1 credit per job (small, fast, ~5s)
- **Premium Image** — 4 credits (larger, more detailed, ~10s)
- **HD Image** — 8 credits (max quality, ~20s)
- **480p video** — 2 credits
- **720p video** — 3 credits
- **1080p video** — 3 credits
A typical content pipeline (one hero per article, four A/B variants per hero, occasional social reposts) uses 5–10 credits per article. On a Starter plan at 400 credits/month, that's 40–80 articles — more than most content teams ship in a month.
The cost advantage of async is not just architectural. It's also **observability**: every job has an ID, every job has a credit cost, and you can attribute credit spend to a specific article, campaign, or customer. With synchronous calls behind an HTTP proxy, that attribution is lost.
Putting it together: a small async pipeline
Here's a complete example — submit a job, poll until done, attach the result to a blog post record:
const client = new MediaEngineClient({ apiKey: process.env.MEDIAENGINE_API_KEY });
async function generateHero(articleId: string, prompt: string) { const job = await client.createJob({ type: "image", inputParams: { prompt, aspect_ratio: "16:9", size: "1024x1024" }, idempotencyKey: `article-${articleId}-hero`, });
const asset = await client.waitForJob(job.mediaJobId, { pollInterval: 3000, timeout: 120_000, });
return asset.outputUrl; } ```
The same function works for video and audio — just change `type` and adjust `inputParams`. The job lifecycle, idempotency, and polling logic don't change.
Pitfalls and FAQ
When is a synchronous call actually fine?
CLI batch jobs that don't care about user-facing latency, one-off scripts, and tests with mocked responses. If your tool is a long-running daemon serving users or running inside an agent loop, default to async.
What if I don't want to host a webhook endpoint?
Poll. It's fine. The webhook is an optimization, not a requirement. For most agents, polling every 3 seconds with a 2-minute timeout is the right default.
How do I keep my job IDs durable?
Store the `mediaJobId` alongside your content record (`article.heroJobId`). When the article is requested, look up the job ID and fetch a fresh signed URL via `GET /api/jobs/{id}/download`. The signed URL is short-lived by design — your job ID is the durable handle.
Can I cancel a queued job?
If the job hasn't started processing yet, you can request a cancellation. Once `status` is `processing`, the model has already begun and cancellation may not be possible. Check the API docs for the current `DELETE /api/jobs/{id}` support.
Why is the model name missing from the job response?
By design. Provider names and routing details are rewritten at the API boundary so callers never see upstream implementation details. The response always shows `provider: "mediaengine"`. This is enforced in the public DTO and the error sanitizer — it's a feature, not a bug.
What happens if a job fails?
The job's `status` flips to `failed` and `errorMessage` is set. The error message is also rewritten to be user-safe (no upstream provider details leak). Retry with a new idempotency key once you've debugged the prompt — the same idempotency key will return the same failed job.
How many jobs can I have in flight at once?
The free tier is one-time (25 credits). Production usage wants a paid plan; the default rate limit is 100 requests per minute, with higher limits on paid plans. For most content pipelines, that's plenty of headroom.
How do I integrate this with an MCP-aware agent?
Install `mcp-media-engine` in the agent's MCP config. The agent gets intent-shaped tools (`generate_article_hero_image`, `generate_social_teaser_image`, `generate_email_header_image`) that wrap the same async job lifecycle under the hood.
CTA
If you're designing a content pipeline that generates images, video, or audio at scale, the [Model Context Protocol specification](https://modelcontextprotocol.io) is the standard for agent-side tool discovery, and the async job pattern above is the standard for media generation. The MediaEngine MCP server (`mcp-media-engine` on npm) and the REST endpoint at `https://api.mcpmediaengine.com/api/jobs` give you both. Sign up at [https://app.mcpmediaengine.com](https://app.mcpmediaengine.com) — the free tier includes 25 credits on signup, no card required. Full docs at [https://mcpmediaengine.com/docs](https://mcpmediaengine.com/docs).
Sources
1. Model Context Protocol Specification, https://modelcontextprotocol.io (accessed 2026-07-07) — the JSON-RPC standard and tool-discovery model referenced in §"CTA" and §"Pitfalls and FAQ". 2. MediaEngine REST API docs, https://mcpmediaengine.com/docs/api — the `POST /api/jobs`, `GET /api/jobs/{id}`, webhook payload, and idempotency semantics referenced throughout. 3. MediaEngine MCP server package, https://www.npmjs.com/package/mcp-media-engine — the `npx -y mcp-media-engine` install path and the agent-side tools cited in §"Pitfalls and FAQ". 4. `@mediaengine/client` SDK README (project repo) — the `waitForJob` helper, `verifyWebhookSignature`, and `createJob` semantics referenced in the lifecycle walkthrough. 5. MediaEngine pricing, https://mcpmediaengine.com/pricing — credit costs and plan tiers cited in §"Cost pattern". 6. Twelve-Factor App: Disposability, https://12factor.net/disposability (accessed 2026-07-08) — the "retry without side effects" principle that justifies the idempotency-key design.