Render Tutorials
Batched image generation with Render Workflows

Add the per-prompt fan-out for batches

⏱ 8 min

In this step you’ll add a new task on top of the existing one. The existing parent (generateThumbnails) handles one prompt across M models. The new task (generateBatch) takes a list of prompts and spawns one generateThumbnails run per prompt. That gives you N x M parallel subtasks from a single trigger.

You’re not modifying the existing task. You’re wrapping it.

Why a wrapper, not a rewrite

The existing single-prompt task already handles model fan-out, retries, the image API call, the overlay composite, and the MinIO upload. All of that is correct for a batched run too. You only need to add the outer loop.

Wrapping also keeps both call shapes alive. The single-prompt task still works for the existing API server in the reference repo. The new batch task is additive.

Before
- # Existing call shape: one prompt, M models
- generateThumbnails(
- title="Why fan-out beats sequential",
- models=["gemini-3-pro-image-preview"],
 
 
 
 
 
 
 
 
style="photorealistic",
template="bottom-bar",
font="inter",
)
- # => [url1]
 
After
 
 
 
 
+ # New call shape: N prompts, M models per prompt
+ generateBatch(
+ prompts=[
+ "Why fan-out beats sequential",
+ "Three retries with backoff",
+ "Idempotent batch outputs",
+ ],
+ models=["gemini-3-pro-image-preview", "gpt-image-1"],
style="photorealistic",
template="bottom-bar",
font="inter",
)
 
+ # => [[url, url], [url, url], [url, url]] (one row per prompt)

Add the batch task

Open the workflow’s entry file and add a new task next to the existing one. The body is small: validate input, fan out one generateThumbnails subtask per prompt, await all, return the matrix.

typescript/workflow-ts/src/index.ts
const MAX_IMAGES_PER_BATCH = 50;
const generateBatch = task(
{ name: "generateBatch" },
async (
prompts: string[],
models: string[],
style: string,
template: string,
font: string,
context = "",
extraPrompt = "",
) => {
if (prompts.length === 0) {
throw new Error("prompts must contain at least one title");
}
if (prompts.length * models.length > MAX_IMAGES_PER_BATCH) {
throw new Error(`batch is capped at ${MAX_IMAGES_PER_BATCH} generated images`);
}
const rows = await Promise.all(
prompts.map((prompt) =>
_generateThumbnails(prompt, models, style, template, font, context, extraPrompt),
),
);
return { prompts, models, results: rows };
},
);
python/workflow-python/main.py
MAX_IMAGES_PER_BATCH = 50
@app.task(name="generateBatch")
async def generate_batch(
prompts: list[str],
models: list[str],
style: str,
template: str,
font: str,
context: str = "",
extra_prompt: str = "",
) -> dict[str, object]:
if not prompts:
raise ValueError("prompts must contain at least one title")
if len(prompts) * len(models) > MAX_IMAGES_PER_BATCH:
raise ValueError(f"batch is capped at {MAX_IMAGES_PER_BATCH} generated images")
rows = await asyncio.gather(
*[
generate_thumbnails(prompt, models, style, template, font, context, extra_prompt)
for prompt in prompts
]
)
return {"prompts": prompts, "models": models, "results": rows}

Run a small batch locally

Restart the local workflow server so it picks up the new task, then trigger a 3 x 2 batch.

Terminal
$render workflows tasks list --local
generateThumbnails generateThumbnail generateBatch
$render workflows start generateBatch --local --input '[["Why fan-out beats sequential","Three retries with backoff","Idempotent batch outputs"], ["gemini-3-pro-image-preview","gpt-image-1"], "photorealistic", "bottom-bar", "inter", "", ""]'
Run started: <run-id> Spawned 3 generateThumbnails subtasks Each spawned 2 generateThumbnail subtasks Result: 3 rows, 2 urls each
Terminal
$render workflows tasks list --local
generateThumbnails generateThumbnail generateBatch
$render workflows start generateBatch --local --input '[["Why fan-out beats sequential","Three retries with backoff","Idempotent batch outputs"], ["gemini-3-pro-image-preview","gpt-image-1"], "photorealistic", "bottom-bar", "inter", "", ""]'
Run started: <run-id> Spawned 3 generateThumbnails subtasks Each spawned 2 generateThumbnail subtasks Result: 3 rows, 2 urls each

Six images should land in the thumbnails bucket. The wall-clock time should be close to the time of one image, not six.

What you learned

  • The batch task wraps the existing single-prompt task instead of replacing it
  • N prompts x M models = N x M parallel subtasks, two levels of fan-out deep
  • Cap the input size to keep paid API spend predictable
  • Wall-clock stays close to a single image's time even as the batch grows