Generating Images on Cloudflare Workers AI
8 min read · updated August 11, 2026
Image generation on Workers AI uses the same binding and the same run() call as text. The difference is entirely in what comes back, and returning it correctly is where the request stops being three lines.
The model and its inputs
Cloudflare’s catalogue lists several text-to-image models. Taking @cf/black-forest-labs/flux-1-schnell as the worked example, the documented inputs are a prompt string with a maximum length of 2048 characters, and an optional integer steps whose default is 4 and whose documented maximum is 8.
That ceiling of 8 is a property of the model, not a platform restriction, and it is worth understanding rather than working around. Schnell is a distilled few-step model: it was trained to reach a usable image in a handful of denoising steps, and raising the count past its designed range buys you latency and neurons rather than fidelity. If you find yourself wanting 30 steps, you want a different model, not a different parameter.
const result = await env.AI.run("@cf/black-forest-labs/flux-1-schnell", {
prompt: "an isometric diagram of a data centre, flat colours, no text",
steps: 4,
});The 2048-character prompt cap is a real edge. If your prompt is assembled from user input plus a style preamble, truncate deliberately — truncate the user portion and keep the preamble — rather than letting the request fail or the tail of your style instructions get cut.
What comes back is base64, not bytes
Cloudflare documents the output of this model as an object with an image field containing the image as a base64-encoded string. It is not a ReadableStream, not an ArrayBuffer and not aBlob. The documented quick path is to build a data URI from it:
const dataURI = "data:image/jpeg;charset=utf-8;base64," + result.image;
A data URI is fine for dropping into an img tag in a server-rendered page and poor for almost everything else. It cannot be cached by URL, it inflates the HTML it is embedded in, and base64 is about a third larger than the bytes it encodes — so a 900 KB image becomes roughly 1.2 MB of string before it becomes 1.2 MB of HTML.
Returning an actual image
The version worth shipping decodes the base64 and returns real bytes with a real content type, so the browser, your CDN and any downstream cache all treat it as an image.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const prompt = new URL(request.url).searchParams.get("prompt");
if (!prompt) return new Response("missing prompt", { status: 400 });
const result = await env.AI.run("@cf/black-forest-labs/flux-1-schnell", {
prompt: prompt.slice(0, 2048),
steps: 4,
});
const binary = Uint8Array.from(atob(result.image), (c) => c.charCodeAt(0));
return new Response(binary, {
headers: {
"content-type": "image/jpeg",
"cache-control": "public, max-age=31536000, immutable",
},
});
},
} satisfies ExportedHandler<Env>;The immutable cache header is only honest if the URL determines the image. Since these models take a seed and are not deterministic without one, either pass a fixed seed derived from the prompt or drop the header. A long max-age on a URL that returns a different image each time is a caching bug that will look like a model bug.
Where this one does cost CPU time
On the text pages the message is that awaiting a model call costs essentially no CPU budget, because Cloudflare meters CPU time as time executing your code rather than time awaiting I/O. Image generation is the case where the second half of the sentence bites.
The atob call plus the Uint8Array.from mapper walk every byte of the decoded image in JavaScript. On the Free plan’s documented 10 ms CPU budget that is a genuine risk for a large image; on Paid, where Cloudflare documents 30 seconds by default, it is not. The other ceiling is memory: Cloudflare documents 128 MB per isolate, and at the moment of the conversion you are holding the base64 string and the decoded array simultaneously, so peak usage is roughly 2.3 times the image size.
When the call fails
Image generation is slower and heavier than text, so it meets a different subset of Cloudflare’s documented Workers AI errors than a chat endpoint does. The four worth branching on:
- 3007 — Timeout, HTTP 408. The documented message is “Request timeout”. Image models are the usual cause, and a higher
stepsvalue makes it more likely. This is retryable, but retry with the sameseedor you have paid for two different images. - 3008 — Aborted, HTTP 408. “Request was aborted”. Frequently your own doing: a client that navigated away, or an
AbortSignalwith a timeout shorter than the model needs. - 3006 — Request too large, HTTP 413. On a text-to-image call this points at the prompt; on an image-to-image or inpainting model it points at the input image you passed.
- 5035 — HTTP 403, “This model requires a Workers Paid plan”. Cloudflare restricts access to some resource-intensive models on the Free plan, and image models are disproportionately in that group. This one is not retryable and not transient.
Separately from the error codes, guard the success path. A response object whose image field is missing or empty will pass straight through atob and produce a zero-length body with an image/jpeg content type, which browsers render as a broken image and caches will happily store for a year if you set the header above. Check the field before you build the response, and return a 502 rather than an empty image, so the failure is visible in your own metrics rather than only in a user’s screenshot.
if (typeof result.image !== "string" || result.image.length === 0) {
return new Response("no image returned", { status: 502 });
}If the image needs to outlive the request
Generating on every request is the wrong shape for anything but a demo: it is slow, it burns neurons at the model’s published rate every time, and it makes the response uncacheable in practice. The usual fix is to key the generation on a hash of the prompt and parameters, write the bytes to object storage once, and serve subsequent requests from there.
- Hash the normalised prompt plus
stepsandseedwithcrypto.subtle.digest("SHA-256", ...)and use the hex digest as the object key. - Look the key up in an R2 bucket binding. On a hit, return the object body directly — no model call, no neurons, no decode.
- On a miss, run the model, decode once, write the bytes to R2, and return them.
- Wrap the write in
ctx.waitUntil()so the client is not made to wait for the store to acknowledge. Cloudflare documentswaitUntilas extending execution for up to 30 seconds after the response completes, which is ample for one object write.
The neuron accounting for this is straightforward once you are counting per generation rather than per request, and the pricing page works through how to turn a published model rate into that figure.