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.
- # 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]
+ # 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.
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 }; },);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.
$render workflows tasks list --localgenerateThumbnails 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
$render workflows tasks list --localgenerateThumbnails 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