Render Tutorials
Batched image generation with Render Workflows

Read the multi-model fan-out

⏱ 6 min

In this step you’ll read the existing parent task and see how it fans one prompt out across the models the caller selected. The pattern is small. Once you’ve internalized it, step 5 is a copy-paste with one extra wrapper.

The two tasks

The reference repo defines two tasks:

  • The parent task (generateThumbnails) takes one prompt and a list of model ids. It spawns one subtask per model and awaits all of them.
  • The subtask (generateThumbnail) takes one (prompt, model) pair, calls the provider’s image API, composites the title overlay, and uploads the JPEG to MinIO.

Subtasks run on their own instances. The parent task is light. Most of its wall-clock time is spent waiting on subtask results.

The parent task, annotated

typescript/workflow-ts/src/index.ts
const generateThumbnail = task(
{
name: "generateThumbnail",
retry: { maxRetries: 2, waitDurationMs: 5000, backoffScaling: 2 },
},
async (title, model, style, template, font, context, extraPrompt) => {
return generateThumbnailImage(title, model, style, template, font, context, extraPrompt);
},
);
const _generateThumbnails = task(
{ name: "generateThumbnails" },
async (title, models, style, template, font, context, extraPrompt) => {
const results = await Promise.all(
models.map((model) =>
generateThumbnail(title, model, style, template, font, context, extraPrompt),
),
);
return { title, style, template, font, results };
},
);
python/workflow-python/main.py
app = Workflows(
default_retry=Retry(max_retries=2, wait_duration_ms=5000, backoff_scaling=2.0),
default_timeout=300,
)
@app.task(name="generateThumbnail")
async def generate_thumbnail(title, model, style, template, font, context="", extra_prompt=""):
return _generate_thumbnail(title, model, style, template, font, context, extra_prompt)
@app.task(name="generateThumbnails")
async def generate_thumbnails(title, models, style, template, font, context="", extra_prompt=""):
subtasks = [
generate_thumbnail(title, model, style, template, font, context, extra_prompt)
for model in models
]
results = await asyncio.gather(*subtasks)
return {"title": title, "style": style, "template": template, "font": font, "results": results}

The subtask call is the fan-out point. Each call to generateThumbnail becomes its own Workflow run with its own retry policy. Promise.all and asyncio.gather are the join: the parent waits until every model finishes, then returns one combined result. Retry belongs on the image-generation subtask because provider failures happen per model, not in the lightweight parent.

What the fan-out looks like at runtime

The parent task is a single instance that lives only as long as it takes the slowest subtask to finish. The subtasks are independent. If Gemini errors and retries, the DALL-E subtask doesn’t notice.

The parent task spawns 3 subtasks. The Gemini subtask succeeds in 4s. GPT-Image-1 fails once, retries with exponential backoff (5s base), and then succeeds in another 12s. DALL-E 3 succeeds in 25s. About how long does the parent task take, end to end?

What you learned

  • The parent task is small. It spawns subtasks and waits
  • Each subtask runs on its own instance, with its own retry policy
  • Parent wall-clock = the slowest single subtask, not the sum
  • A retry on one subtask doesn't slow down the others