Vibe Engines
YouTube
AI System Design

Design a Text-to-Image Service

Step 1 / 9

Learn AI system design by building a text-to-image generation service like Midjourney, DALL·E or a hosted Stable Diffusion step by step.

The numbers to beat202 + job idinstant acceptpoll / subscribeget the resultstatusqueued → done

The whole design, in writing

Learn AI system design by building a text-to-image generation service like Midjourney, DALL·E or a hosted Stable Diffusion step by step. An interactive guide covering why synchronous generation fails, the async job pattern, the diffusion inference loop, a batched autoscaling GPU worker fleet, storing and delivering images via object store + CDN, input and output safety moderation, and the unhappy paths (cost, queue priority, cold starts).

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What makes text-to-image hard?

A user types a sentence and expects a picture. Behind that, a diffusion model runs dozens of iterative denoising steps on a GPU, taking seconds to tens of seconds and a big chunk of very expensive hardware — per image. Now serve that to millions of users whose demand spikes unpredictably, safely, without setting money on fire.

Usera prompt
New in this step: User.

A text-to-image service is really a GPU job system with safety on both ends. The shape: accept a prompt, run it as an async job on a batched, autoscaling GPU fleet, store the result and deliver it via CDN, and moderate both the prompt and the image. The hard parts are latency, cost, and safety — not the web plumbing.

What the new pieces do

Userclient
Someone typing "an astronaut riding a horse, oil painting". Wants an image back — and doesn't mind waiting a few seconds.

Step 1 · Why not just return it?

Synchronous generation fails

The obvious API: POST /generate, run the model, return the image in the response. Why does this collapse the moment you have real traffic?

Usera prompt
The system as it stands at this step.

A synchronous request that runs the model and returns the image. Why does it fail at scale?

  1. Each generation occupies an expensive GPU for seconds to tens of seconds; holding the HTTP request open the whole time ties up server resources, times out, and means a traffic burst instantly exceeds fixed GPU capacity with no way to buffer. You need to decouple accepting the request from doing the work.

  2. Image size is trivial (and you'd return a URL anyway). The real problem is that generation is slow and GPU-bound, so a synchronous request-per-job model can't hold connections that long or absorb spikes against fixed capacity.

  3. HTTP carries anything. The issue is duration and capacity: a multi-second GPU job per request doesn't fit the synchronous request/response model under load.

Generation takes seconds to tens of seconds on scarce, expensive GPUs. Holding an HTTP request open for each job ties up resources, hits timeouts, and — fatally — a demand spike instantly overruns your fixed GPU capacity with nowhere to buffer. You must decouple accepting the request from running the slow, capacity-limited work.

Why this piece earns its place

The first thing to break under a synchronous design isn't the GPU — it's whatever sits in front of it. Load balancers and proxies carry idle timeouts shorter than a slow generation, and when one fires the client's connection dies while the worker keeps denoising: you pay for that image in full and nobody receives it. Worse, a client that times out retries, so at the exact moment you are over capacity every user doubles their own load on the scarcest thing you own — congestive collapse on hardware you cannot add in seconds. Skip the pattern when the numbers genuinely allow it, on an internal tool with a handful of users, where a queue is machinery you maintain for nothing. The deeper reason to draw the boundary anyway is that synchronous work is anonymous. Once a generation is a job with an id it becomes something the rest of the system can name: cancelled when the user closes the tab, attributed to whoever pays for it, pointed at by the status record, by the stored image and by the audit trail. A request in flight gives you nothing to attach any of that to — when the socket dies, every trace of what the user wanted dies with it.

Step 2 · Accept fast, work later

The async job API

If you can't return the image inline, what does the API do the instant a prompt arrives?

Usera promptAPIsubmit · status
New in this step: API.

Make generation an async job. POST /generate validates the request, enqueues a job, and immediately returns a job id. The client then polls GET /jobs/:id or subscribes (WebSocket/SSE/webhook) for completion, receiving the image URL when it's ready. The API is fast and stateless; the slow work happens behind the queue. Track each job's status (queued → running → done/failed) so the client always knows where it stands.

Why this piece earns its place

The load nobody sizes for is the status path. One generation is a single write, but the client asks after it again and again until the picture lands, so reads dominate writes by a wide margin and each one has to be a single lookup by primary key — never a scan, never a join across the queue. Hand clients an explicit poll interval and widen it as the wait grows, or push. If you push, the record still has to be authoritative and the socket only an accelerator: a client that reconnects must be able to ask where things stand and get the same answer, or a dropped connection becomes a job that finished for nobody. Then decide what the record says when things go wrong. failed is not a state, it is a family — a rejected prompt, an out-of-memory worker and a malformed request want different reactions, so the terminal state carries a reason and whether retrying is worth anything. And because that record holds the prompt and later the image URL, the job id is a capability: make it unguessable and check ownership on every status read, not just on submit.

  • 202 + job idinstant accept
  • poll / subscribeget the result
  • statusqueued → done

What the new pieces do

APIgateway
Accepts a generation request, returns a job id immediately, and lets the client poll or subscribe for the result.

Step 3 · Buffer the burst

A job queue in front of the GPUs

Demand is spiky (a viral moment, a launch) but GPU capacity is fixed and can't scale in seconds. How do you keep a spike from either dropping requests or requiring you to over-provision hugely?

Job Queuepending jobsGPU Workerdiffusion loopJob Statusqueued→done
New in this step: Job Queue, GPU Worker, Job Status.

Spiky demand, fixed GPU capacity that can't scale instantly. How do you cope?

  1. GPUs are extremely expensive; provisioning for peak wastes enormous money sitting idle most of the time, and you'll still be caught by a bigger-than-expected spike. A queue lets you provision for sustainable throughput and absorb bursts.

  2. Dropping paid requests during your busiest moments is the worst outcome. Buffer them instead — users happily wait a bit for a generated image.

  3. The queue absorbs bursts: jobs pile up and drain as GPUs free up, so a spike becomes a longer wait rather than dropped requests or wasted idle capacity. You provision for sustainable throughput, and the queue handles the peaks.

Put a job queue between the API and a pool of GPU workers. The API enqueues; workers pull jobs at their own sustainable rate. A demand spike simply lengthens the queue (and the ETA) instead of dropping requests or forcing you to over-provision GPUs for peak. You size the fleet for steady throughput; the queue absorbs the bursts. This is the shock absorber that makes fixed, costly capacity workable.

Why this piece earns its place

A queue absorbs a burst, but it does not have unlimited useful depth. Past some backlog the honest answer changes: a job accepted with a wait longer than the user will stay is worse than a refusal, because they are gone and you still spend the GPU seconds rendering for nobody. So the door needs a policy, and the number it reads is the time the current fleet needs to drain what is already queued, not a raw count of jobs — past that line, decline quickly or offer a cheaper variant, while the backlog still looks perfectly healthy on a graph. The second thing a queue changes is delivery semantics. A worker can die mid-denoise, and unless you have deliberately written the half-finished latent somewhere, none of that work survives: whatever the worker pulled becomes visible again to the fleet once its lease expires, and the next worker starts the generation from noise. So a job runs at least once and occasionally twice. Design the downstream write for that now — key the stored image by job id so a rerun overwrites rather than leaving two images the status record has to choose between, and write the image before marking the job done. Reverse that order and a crash in the gap gives you a job marked finished with nothing to serve.

  • queueabsorbs spikes
  • pullworkers at their rate
  • size for throughputnot peak

What the new pieces do

Job Queuebus
Holds pending generation jobs, decoupling bursty demand from the fixed, expensive GPU fleet. Absorbs spikes.
GPU Workerservice
Pulls a job, runs the iterative denoising loop on a GPU (batched with others), and writes the finished image out.
Job Statusdata
Tracks each job's state and final image URL, so the API can answer "is it ready?" without holding the connection.

Step 4 · Inside the box

The diffusion inference loop

A worker has a job — now it actually generates the image. What is the model doing, and why is it so compute-heavy?

GPU Workerdiffusion loopDiffusion Modelweights · latent
New in this step: Diffusion Model.

The worker runs the diffusion loop: start from random noise in a compressed latent space, then iteratively denoise it over many steps, at each step nudging it toward the text prompt (via cross-attention on the prompt embedding, with a guidance scale controlling how strongly). After the final step, a decoder turns the clean latent into pixels. Each of the dozens of steps is a full neural-network pass — which is why it's slow and GPU-bound. Fewer steps = faster but lower quality; that tradeoff is a key dial.

Why this piece earns its place

Reproducibility here is something you engineer, not something the loop hands you. The same prompt and seed only return the same picture if the scheduler, the weights version and the numeric precision are pinned alongside them — and once the next step batches jobs onto one GPU, batch shape and GPU model can change which kernels run and therefore the last bits of the image. Treat that as a design requirement rather than a pleasant property, because it is load-bearing: regenerating, sharing a seed and reproducing a complaint all depend on it, and it makes the weights version state rather than configuration. Record the weights version, the scheduler, the seed and the settings on every result, and ship new weights as a new version instead of an upgrade in place. A user who saved a seed, came back and got a different picture has lost something they thought was theirs, and nothing in your telemetry will report it — no error, no latency change, just a report you cannot reproduce because the model that made the original is gone. Which settles where the knobs live: steps and guidance in the request, where a caller can trade quality for wait; the model version in config, and on the result.

  • noise → imageiterative
  • ~20–50denoising steps
  • guidanceprompt adherence dial

What the new pieces do

Diffusion Modeldata
The loaded model weights. Denoises a random latent toward the prompt over many steps, then decodes to pixels.

Step 5 · Feed the fleet

Batched, autoscaling GPU workers

GPUs are most efficient when kept busy with parallel work, they're expensive, and demand rises and falls. How do you run the worker fleet cost-effectively?

UserAPIJob QueueGPU WorkerDiffusion ModelJob Status
The system as it stands at this step. · swipe to pan the diagram

Batch multiple generation requests together on each GPU — GPUs are throughput machines, so processing several images in parallel dramatically raises utilization and lowers cost per image. Autoscale the worker pool on queue depth (more workers when the backlog grows, fewer when it drains) to track demand without paying for idle peak capacity. Keep model weights resident (loading a multi-GB model is slow) and manage cold starts by keeping a warm pool. Route different models/resolutions to appropriate GPU types.

Why this piece earns its place

Batching is not free, and the bill is paid in latency. You either hold a job while a batch fills, adding wait to every request in order to help the ones behind it, or you launch half-empty batches and give away the throughput you batched for. That fill window is the real knob, and it belongs per model, because the right value depends on arrival rate rather than on hardware. A batch is also a unit. Jobs in it finish together, so a twenty-step request sharing a batch with a fifty-step one waits for the fifty — batch by shape, matching model, resolution and step count, or your fast path silently inherits the slow path's latency while each job's own duration still reads as normal. The same unit rule governs failure. Resident weights are a fixed floor; everything above them scales with batch size times the pixel count, so doubling an image's width and height is roughly four times the working set, and attention over the latent grid grows faster still. An out-of-memory kills every job in the batch, not just the one that crossed the line, so retry the survivors alone. And queue depth is the right signal to scale on, but it counts jobs that are not equal — pair it with how long the oldest job has waited, because a new node boots and then pulls multi-gigabyte weights before it takes anything.

  • batchparallel on GPU
  • autoscaleon queue depth
  • warm weightsavoid cold start

Step 6 · Store and deliver

Object store + CDN

The worker produced an image. Where does it go, and how does the user (and anyone who views it later) get it fast and cheaply — without shuttling image bytes back through your job system?

Image Storeobject storageCDNdeliver
New in this step: Image Store, CDN.

Write the finished image to object storage and record its URL in the job result — the API returns that URL rather than passing image bytes around. Serve the image through a CDN so the user (and any subsequent views, shares, or galleries) fetch it from a nearby edge, cheaply and fast. Generated images are immutable and often re-viewed, making them ideal CDN content. Store thumbnails/variants too if the product needs them.

Why this piece earns its place

The URL is the permission. Write generations into a public bucket and every one of them is readable by anyone holding the link, which includes anyone handed it by a log line, a referrer header or a screenshot. Private output needs signed URLs, and that immediately fights the CDN: a signature scoped to one viewer is a different URL per viewer, so the edge caches nothing and you pay origin egress on every view. The ways out are all deliberate — sign at the edge, carry the signature in a cookie instead of the path, or accept that only public galleries get cached — and choosing late means changing how every image in the product is addressed. The other thing storage does is never shrink. Generations accumulate, and unlike GPU seconds this bill arrives every month forever, so anything not explicitly kept needs an expiry. That turns the URL in the job record into a claim rather than a guarantee: it can be gone a month later, so the record should carry the lifetime and the client should be told the image expired instead of being handed a dead link. Keep that lifetime short enough that a takedown you cannot confirm reached every edge still ends on its own.

  • object storedurable images
  • URLnot bytes in the API
  • CDNfast, cheap delivery

What the new pieces do

Image Storedata
Durable object storage for finished images; the job result points here rather than passing image bytes around.
CDNcache
Serves the generated image to the user (and any future views) from a nearby edge, cheaply and fast.

Step 7 · Both ends of safety

Prompt and image moderation

Image generation is uniquely risky: a benign-looking prompt can produce harmful, illegal, or NSFW imagery, and users will deliberately try. Filtering text alone is not enough — you have to check what the model actually drew. How do you make it safe?

Safetyin + out moderationAPIsubmit · status
New in this step: Safety.

A harmless-looking prompt can still yield harmful imagery. What's required?

  1. You screen the prompt before generation (block obvious policy violations, prompt tricks) AND run the generated image through visual moderation (NSFW/CSAM/violence classifiers) before delivery — because the model can produce disallowed content from prompts that look innocuous. Text filtering alone can't see the pixels.

  2. Prompt-only filtering misses the core risk: the image itself. Innocuous or obfuscated prompts can still yield harmful pixels, so you must moderate the output image, not just the input text.

  3. For image generation, after-the-fact moderation is far too late and legally unacceptable for the worst content. You must gate both input and output proactively, before delivery.

Moderate on both ends. Input: screen the prompt (policy violations, known evasion tricks) before spending a GPU on it. Output: run every generated image through visual moderation (NSFW / violence / CSAM classifiers) before it's ever delivered — because the model can draw disallowed content from prompts that look harmless. Block, blur, or reject on a hit, and log for review. Add provenance (watermarking / C2PA) so generated images are identifiable. Safety here is mandatory and defense-in-depth.

Why this piece earns its place

Every one of these checks is a threshold, and its two ways of being wrong do not cost the same. A false positive is a paying user who waited, received nothing, and now wants an explanation. A false negative in the worst categories has no ceiling at all. So the cutoff is set per category rather than once for the whole service — severe where the downside is unbounded, looser where the cost is an irritated customer — and you decide explicitly whether a blocked generation is billed, because those GPU seconds were genuinely spent and somebody will ask. The harder problem is that the failure that matters cannot be measured from production data: blocks are counted, but what slipped through is by definition unlabelled, so the miss rate never reaches a dashboard and the graph looks calm either way. That number comes from sampled human review and deliberate red-teaming against the current weights, repeated whenever the model or the classifier changes. Two smaller consequences. The output check sits on the critical path behind the slowest stage in the design, so it has to be a small model rather than a second large one. And the evidence you retain for review is the very material you are containing — restrict who can open it, and give it a retention limit.

  • inputscreen the prompt
  • outputscreen the image
  • provenancewatermark / C2PA

What the new pieces do

Safetyservice
Moderates the prompt before generation and the image after — both are required, since the model can draw what text filters miss.

Step 8 · The sharp edges

Cost, priority & cold starts

The realities of running expensive generative infra: GPU cost dominates everything, free and paid users compete for the same fleet, latency varies, and identical prompts get regenerated.

UserSafetyAPIJob QueueGPU WorkerDiffusion ModelImage StoreCDNJob Status
The system as it stands at this step. · swipe to pan the diagram

Control cost aggressively — GPU time is the bill — with batching, right-sized GPUs, step-count tuning, and per-user quotas. Prioritize the queue (paid/interactive jobs jump ahead of free/batch) so premium users get low latency while free traffic fills spare capacity. Manage cold starts with warm pools and by keeping popular models resident. Cache/dedupe identical prompt+seed requests. Set honest ETAs and stream progress (or intermediate previews) so a multi-second wait feels responsive. And handle failures with idempotent retries that don't double-charge GPU time.

Why this piece earns its place

A strict priority queue starves. If paid work arrives continuously, free jobs are never at the front, and the queue meant to hold them becomes the place that work goes to die — no error anywhere, just a class of users whose images never come. The fixes are aging, where a job's effective priority climbs with the time it has already waited, or reserving a slice of the fleet for the lower class outright; reservation is easier to reason about and to explain to the person paying for it. Preemption is the other half, and it is a choice rather than a constraint. The loop's live state is one latent plus a step index — the same intermediates you decode for previews — so a running job can be checkpointed and picked up elsewhere. What that costs is moving tensors off the GPU and a guarantee that the receiving worker holds identical weights and scheduler, which is why most fleets skip it and preempt only at job boundaries. Either way, say which you chose, because it sets the promise: at job boundaries, an urgent job arriving at a full fleet waits a whole generation at best. And none of these levers work unless the job carries its own cost — tag it at submit with user, model and the settings that drive GPU time, or the quota you enforce is a guess about a resource you never measured.

You did it

You just designed a text-to-image service.

UserSafetyAPIJob QueueGPU WorkerDiffusion ModelImage StoreCDNJob Status
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • Synchronous generation fails: jobs are slow and GPU-bound, so you can't hold a request or absorb spikes.
  • Make it async: accept fast (job id), run behind the scenes, let the client poll/subscribe for the result.
  • A job queue between API and GPU workers absorbs bursts and lets you size for throughput, not peak.
  • The worker runs the diffusion loop — iterative denoising in latent space toward the prompt.
  • Batch requests on each GPU and autoscale the fleet on queue depth; keep weights warm.
  • Write images to object storage and deliver via CDN — pass a URL, never bytes through the API.
  • Moderate both the prompt and the generated image; add provenance. Control GPU cost and prioritize the queue.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why must text-to-image generation be asynchronous?

    Because each generation is both slow and runs on scarce, expensive hardware, which together break the synchronous request/response model. A single image takes seconds to tens of seconds of GPU time (dozens of sequential denoising steps, each a full model pass), so holding an HTTP request open for the whole job ties up server resources, risks timeouts, and scales terribly. More fundamentally, GPU capacity is fixed and can't be conjured in seconds, so if requests are served synchronously, a demand spike instantly exceeds capacity with nowhere to buffer — you'd have to either drop requests or over-provision hugely for peak. The async pattern fixes both: the API accepts the request, enqueues a job, and immediately returns a job id, while a queue in front of the GPU workers absorbs bursts as backlog and the client polls or subscribes for the result. Whenever work is slow and capacity is fixed and costly, you queue it — image generation is a textbook case.

  2. What is the diffusion model actually doing, and why is it so compute-heavy?

    A diffusion model generates an image by reversing a noising process. It starts from pure random noise in a compressed latent space and then iteratively denoises it over many steps, and at each step it predicts and removes a little noise while conditioning on the text prompt (through cross-attention on the prompt embedding, with a guidance scale that controls how strongly the image is pulled toward the prompt). After the final step, a decoder converts the clean latent into actual pixels. The reason it's expensive is that this denoising is inherently sequential and takes many steps — typically twenty to fifty — and each step is a full forward pass through a large neural network, so a single image is dozens of model evaluations rather than one. Working in a smaller latent space instead of full-resolution pixels is the key optimization that makes it tractable at all. The number of steps is a direct quality-versus-speed dial: fewer steps generate faster but with lower fidelity, so step-count tuning is one of the main levers for balancing latency, quality, and GPU cost.

  3. Why is a queue the right way to handle spiky demand on fixed GPU capacity?

    Because the mismatch between bursty demand and fixed, expensive supply is exactly the problem a queue solves. GPUs are costly and can't scale in seconds, so you have two bad options without a buffer: over-provision the fleet for peak demand, which wastes enormous money on hardware sitting idle most of the time and still gets caught by a larger-than-expected spike; or reject requests when GPUs are busy, which drops paid traffic at your most popular moments. A job queue between the API and the worker pool eliminates both: during a spike, jobs simply accumulate and the ETA lengthens, and as GPUs free up they pull the backlog down, so a burst becomes a longer wait rather than dropped work or idle capacity. This lets you size the fleet for sustainable throughput rather than peak, run GPUs near full utilization, and it gives you a natural place to prioritize jobs (paid ahead of free) and meter usage. It's the same shock-absorber role a durable queue plays in any system where a fast producer meets a slower, capacity-limited consumer.

  4. How do you keep an expensive GPU fleet cost-effective?

    The dominant cost in a generative image service is GPU time, so the entire operational game is maximizing useful work per GPU-second and not paying for idle hardware. The first lever is batching: GPUs are throughput machines that are underutilized by a single request, so you process multiple generation requests in parallel on each GPU, which dramatically raises utilization and lowers the cost per image. The second is autoscaling the worker pool based on queue depth — adding workers when the backlog grows and removing them when it drains — so fleet size tracks real demand instead of being provisioned for peak, with the queue smoothing bursts into steady utilization. Because model weights are multiple gigabytes and slow to load, you keep them resident and maintain a warm pool to avoid cold-start latency when scaling up. Beyond infrastructure, you tune the number of denoising steps (fewer steps is less compute for a quality tradeoff), route jobs to right-sized GPU types per model and resolution, cache or dedupe identical prompt-and-seed requests, and enforce per-user quotas. Every one of these trades against the same scarce resource — expensive GPU seconds — which is why optimizing them is effectively the business.

  5. Why does image generation need moderation on both the input and the output?

    Because the risk lives in two different places and filtering only one leaves the other wide open. Input moderation screens the prompt before you spend a GPU on it, blocking obviously disallowed requests and known evasion tricks — this saves compute and stops clear intent. But prompt filtering fundamentally can't see what the model will actually draw: a prompt that looks completely innocuous, or is deliberately obfuscated, can still cause the model to generate harmful, illegal, or NSFW imagery, and text classifiers have no visibility into the resulting pixels. So you also need output moderation — running every generated image through visual classifiers (for NSFW, violence, and especially CSAM) before it is ever delivered — as the last line of defense that inspects the actual content. On a hit you block, blur, or reject and log for review. For the most serious content categories this two-sided, defense-in-depth approach is not a nice-to-have but a legal and platform necessity, and it's typically paired with provenance measures like watermarking or C2PA metadata so generated images remain identifiable downstream.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. Text-to-image generation must be asynchronous because…

    Slow work on fixed, costly capacity forces accept-fast, queue, and poll/subscribe for the result.

  2. The diffusion model generates an image by…

    Many sequential denoising passes in latent space is why it's slow and GPU-heavy.

  3. A job queue in front of the GPU workers lets you…

    A spike becomes backlog + longer ETA instead of dropped requests or idle over-provisioning.

  4. GPU cost is controlled primarily by…

    Keep GPUs busy with parallel work and match fleet size to the live queue; tune steps.

  5. Safe image generation requires…

    Text filters can't see the pixels; harmless-looking prompts can still yield harmful images.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Generate: take a text prompt and return an image, run as an async job on a GPU fleet.
  • Track the job: return a job id immediately; let the client poll or subscribe for the result.
  • Run diffusion: iteratively denoise a random latent toward the prompt, then decode to pixels.
  • Deliver: write the image to object storage and serve it via CDN — a URL, never bytes.
  • Moderate: screen the prompt before generation and the image before delivery.

The qualities that shape everything

Each one names the mechanism that buys it.

Don’t hold a request for a slow GPU job
Make generation async — POST /generate enqueues and returns a job id immediately; the client polls or subscribes for the image URL when it’s ready.
Absorb spikes on fixed GPU capacity
A job queue sits between the API and GPU workers that pull at their own rate — a spike lengthens the queue and ETA instead of dropping requests or over-provisioning for peak.
Run expensive GPUs economically
Batch multiple requests per GPU for throughput and autoscale the worker pool on queue depth, keeping model weights resident to avoid cold starts.
Fast, cheap delivery of re-viewed images
Write the finished image once to object storage and serve it through a CDN from a nearby edge; the result is a URL, never bytes through the API.
Never ship harmful pixels
Moderate on both ends — screen the prompt before spending a GPU, and run every generated image through visual moderation before delivery, since text filters can’t see what the model drew.
Premium latency under mixed load
Prioritize the queue so paid/interactive jobs jump ahead of free/batch, manage cold starts with warm pools, and cache identical prompt+seed requests.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Decouple accept from run (async) over a synchronous request that runs the model and returns the image

Each job occupies an expensive GPU for seconds to tens of seconds, so holding the HTTP request ties up resources, times out, and a burst instantly overruns fixed capacity with nowhere to buffer. Slow work on fixed hardware must be queued.

A job queue absorbing bursts over over-provisioning GPUs for peak

GPUs are extremely expensive; provisioning for peak wastes money sitting idle and still misses a bigger spike. A queue turns an unservable spike into a longer wait, letting you size for sustainable throughput.

Batch on GPU + autoscale on queue depth over one request per GPU sized for peak

GPUs are throughput machines underused by a single request; batching parallel work raises utilization and autoscaling on the live queue matches fleet size to demand — the enemy is idle expensive hardware.

Write to object storage, deliver via CDN over passing image bytes back through the queue/API

Never move large binaries through your job system — write once to durable storage and pass a URL. Generated images are immutable and re-viewed, so a CDN handles the read fan-out cheaply and fast.

Moderate prompt AND image over filtering the prompt text only

Prompt-only filtering misses the core risk — the pixels. An innocuous or obfuscated prompt can still yield harmful imagery, so output visual moderation before delivery is the last line text filters can’t provide.

The answer, out loud

What a strong answer to “Design a Text-to-Image Service” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0-3 min

    Name the unit you are designing against

    Before I draw anything I want to name the unit I'm designing against, because it isn't the request — it's the GPU-second. That's the thing I'm buying, rationing and billing, and every decision after this is a claim on the same scarce one. So the questions I'd keep coming back to are who gets to spend a GPU, how full it is while they spend it, and whether anything came out the far end that we can't ship. That framing is also why I'd spend almost no time on the web tier: it's the cheap part and it isn't where this breaks. Latency, cost and safety are the three, and all three are denominated in that same second.

  2. 3-8 min

    Accept fast, work later

    So the first call I'd make is that generation is asynchronous. The obvious API — post a prompt, run the model, return the image — collapses the moment there's real traffic. Each job holds a GPU for seconds, so holding the HTTP request open alongside it ties up resources and hits timeouts, and a burst instantly overruns capacity I can't grow in seconds. Instead, post to generate, validate, enqueue, and hand back a job id immediately. The client then polls that job or subscribes, and gets the image URL when it's ready. The front door stays fast no matter how backed up the GPUs are, and every job carries a status the client can read: queued, running, done or failed.

    Built in step 2: The async job API
  3. 8-14 min

    Put a queue in front of the fleet

    Between the API and the workers I'd put a job queue, and let workers pull from it at their own rate. This is the piece I'd defend hardest. Demand is spiky — a launch, a viral moment — while GPU capacity is fixed and expensive. Without a buffer I have two bad options: over-provision for peak and pay for idle hardware most of the day, or reject requests exactly when I'm busiest. A queue turns an unservable spike into a longer backlog and a longer ETA, and nothing drops. That lets me size the fleet for sustainable throughput rather than peak, and the backlog is also the natural place to prioritize work later.

    Built in step 3: A job queue in front of the GPUs
  4. 14-20 min

    What the worker is actually doing

    Inside the worker, the model starts from random noise in a compressed latent space and iteratively denoises it toward the prompt, with cross-attention on the prompt embedding and a guidance scale controlling how hard it's pulled. A decoder turns the final clean latent into pixels. The thing I'd point out is that each of those twenty to fifty steps is a full pass through the network, and they're sequential, so I can't split one image across two GPUs to make it arrive faster. Per-image latency is essentially settled there. Step count is the direct quality-for-speed dial, and it's a knob I'd expose per request rather than fix in config.

    Built in step 4: The diffusion inference loop
  5. 20-27 min

    The economics are all throughput

    Since per-image latency is fixed, the only axis left is throughput, and that's where the money is. GPUs are throughput machines and a single request underuses them, so I'd batch several generations per GPU, and autoscale the pool on how backed up the queue is — more workers as the backlog grows, fewer as it drains. Weights are multiple gigabytes and slow to load, so I'd keep them resident and hold a warm pool instead of paying a cold start on every scale-up. I'd also route by model and resolution to appropriately sized GPUs. The enemy the whole way through is expensive hardware sitting idle.

    Built in step 5: Batched, autoscaling GPU workers
  6. 27-32 min

    The result is a URL

    When the image is finished, the worker writes it to object storage and the job result carries a URL — I never pass image bytes back through the queue or the API. Delivery goes through a CDN. Generated images are immutable and they get re-viewed, shared, dropped into galleries, so that read fan-out belongs at an edge near the user rather than on my origin. If the product needs thumbnails or variants, they're written at the same moment, for the same reason: once, off the critical path, addressed by URL.

    Built in step 6: Object store + CDN
  7. 32-38 min

    Safety on both ends

    Safety I'd put on both ends, and I'd be firm that it isn't optional. On the way in I screen the prompt, which blocks clear policy violations and known evasion tricks before I spend a GPU on them. On the way out, every generated image goes through visual moderation before delivery — NSFW, violence and CSAM classifiers — because a prompt that reads as completely harmless can still make the model draw something I can't ship. Text filtering can't see pixels, so output moderation is the last line and the only one that inspects what was actually made. I'd add provenance too, watermarking or C2PA, so what we generate stays identifiable downstream.

    Built in step 7: Prompt and image moderation
  8. 38-45 min

    Close on the trade-off

    The rest is the unhappy path, and most of it is cost. GPU time is the bill, so I'd control it with batching, step tuning, right-sized GPUs and per-user quotas, prioritize the queue so paid and interactive jobs jump ahead of free and batch, keep cold starts off the critical path with warm pools, and cache identical prompt-and-seed requests. The trade-off I'd own out loud is that every one of those buys throughput and cost at the expense of somebody's latency — the queue I introduced deliberately is also the wait a user feels. With more time I'd spend it there: honest ETAs, streamed progress and intermediate previews, so the wait I chose at least reads as progress.

    Built in step 8: Cost, priority & cold starts

What this teaches

Learn AI system design by building a text-to-image generation service like Midjourney, DALL·E or a hosted Stable Diffusion step by step. An interactive guide covering why synchronous generation fails, the async job pattern, the diffusion inference loop, a batched autoscaling GPU worker fleet, storing and delivering images via object store + CDN, input and output safety moderation, and the unhappy paths (cost, queue priority, cold starts).

Key takeaways

  • Synchronous generation fails: jobs are slow and GPU-bound, so you can't hold a request or absorb spikes.
  • Make it async: accept fast (job id), run behind the scenes, let the client poll/subscribe for the result.
  • A job queue between API and GPU workers absorbs bursts and lets you size for throughput, not peak.
  • The worker runs the diffusion loop — iterative denoising in latent space toward the prompt.
  • Batch requests on each GPU and autoscale the fleet on queue depth; keep weights warm.
  • Write images to object storage and deliver via CDN — pass a URL, never bytes through the API.
  • Moderate both the prompt and the generated image; add provenance. Control GPU cost and prioritize the queue.

Concepts covered

  • What makes text-to-image hard?
  • Synchronous generation fails
  • The async job API
  • A job queue in front of the GPUs
  • The diffusion inference loop
  • Batched, autoscaling GPU workers
  • Object store + CDN
  • Prompt and image moderation
  • Cost, priority & cold starts
RUN IT YOURSELF

The denoising passes spent where the picture is not changing yet

Step count is the GPU bill, so this runs the real deterministic sampler two ways over the same seeds — noise levels spaced evenly, and spaced on a cosine schedule — and prints how far each lands from the image an exact solve would have produced. The denoiser is not a stand-in: the prior is chosen so the optimal denoiser and the exact answer both have closed forms, which is what makes the error column an exact number. Change STEP_COUNTS, hit Run, and read the two columns against each other.

HOW TO READ THE CODE — 5 IDEAS
  1. Both schedules start at the same SIGMA_MAX, end at the same SIGMA_MIN, and spend the same number of denoiser passes — each pass is one full network pass on a GPU (step 4). Only the interior spacing differs, so the whole gap is the schedule.
  2. At 2 passes the two are byte-identical, because with only the endpoints there is no schedule yet. Every row after that is the spacing alone — and the line under the table is the other end of the range: past ~200 passes both columns reach the same SIGMA_MIN -> 0 floor and the ratio decays back toward 1x.
  3. Cosine reaches in 8 passes an error linear needs 74 for: 9.2x the GPU seconds per image for the same picture. That is step 8’s step-count tuning with a number on it.
  4. Why: linear leaves one drop of 287x in noise level while cosine’s worst is 23x, and it burns 7 of 8 passes above sigma=1, where even the strongest mode is under half recovered. The steps land where nothing has resolved yet.
  5. So the schedule is a throughput decision, not a taste one — it moves cost per image without touching the fleet, the batch size or the queue (step 5).
CPython · WebAssembly
built to turn a sentence into a picture on a fleet of hungry GPUs — make the calls, flood the queue, run the gauntlet.
Finished this one? 0 / 65 AI System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More AI System Designs