Design problems
AI-era systemsHardSDE-2 35 min

Design an AI image generation service

Asked at
AdobeGoogleMetaMicrosoftNVIDIAOpenAI

The interviewer asks

“Design a text-to-image service like Midjourney or the image feature inside a chat assistant. Users type a prompt, wait a few seconds, and get four images. Support free and paid tiers, and make sure the service does not generate illegal or harmful content.”

What a strong answer looks like

A strong answer treats this as a GPU job system, not a request/response API. Generation takes seconds of expensive GPU time, so the API validates the prompt, reserves credits, enqueues a job and streams progress back over SSE. GPU workers pull jobs from priority queues (paid fast lane, free lane, per-user concurrency caps), keep model weights warm in GPU memory, batch requests that use the same model, and write results to S3 behind a CDN. Safety is a pipeline on both sides: a prompt classifier before any GPU time is spent and an image classifier plus hash matching on every output before the user sees it. The estimates drive the design: GPU-seconds per image decide fleet size and cost per image. Senior candidates discuss model and LoRA routing, cold starts of multi-GB weights, fairness under festival-day spikes, credit accounting that never double-charges, and provenance labelling (C2PA, invisible watermarks).

Concepts to mention

async job queue with SSE progressGPU-seconds per image drives fleet size and costpull-based GPU workers with model affinitybatching and cold-start of model weightspriority lanes, per-user concurrency caps, admission controllease / visibility timeout and re-enqueuecredit reservation ledger with idempotency keyprompt and output safety classifiers plus hash matchingC2PA provenance and invisible watermarkS3 + CDN with signed URLs

1 Requirements

Functional

  • Text-to-image: prompt plus options (aspect ratio, style, number of images, seed) returns 4 images
  • Live progress: queue position, then step progress with blurry previews
  • Variations, upscale and inpainting (edit a masked region) on existing images
  • Free tier with daily credits; paid tiers with fast generation and higher concurrency
  • Gallery: history of a user's images, downloadable, shareable by link
  • Safety: block disallowed prompts and outputs, with an appeal/report path
  • Every image carries provenance metadata saying it is AI-generated

Non-functional

  • Paid tier: first image ready p95 < 15 s from submit; free tier p95 < 60 s at peak (may queue)
  • Scale: 5M images/day (1.25M jobs of 4), peak 3× average during evenings and festivals
  • GPU utilisation > 70% averaged over the day; cost per image tracked and under ~$0.003
  • No job lost and no credit charged twice, even if a GPU node dies mid-generation
  • Safety: 0 tolerance for CSAM; output check on 100% of images before delivery
  • API availability 99.9%; image delivery through CDN with 99.95%

Out of scope (say it out loud)

  • Training the diffusion model itself
  • Video generation
  • Payment processing for subscriptions (assume a billing service exists)
  • Social feed / community features

2 Estimates

Image rate~58 images/s avg, ~175/s peak5M images/day ÷ 86,400 ≈ 58/s; ×3 for evening and festival peaks ≈ 175/s
GPU time per image~2 GPU-secondsAssumption: an SDXL-class model, 1024×1024, ~30 denoising steps takes ~3 s alone on one H100-class GPU; a batch of 4 in ~8 s → ~2 s per image. Distilled 4-8 step models cut this 3-5×
GPU fleet at peak~500 GPUs175 images/s × 2 GPU-s = 350 GPUs busy; at a 70% utilisation target ≈ 500 GPUs. Off-peak needs ~170, so autoscaling or spot capacity matters
Cost per image~$0.002 GPU2 GPU-s × (~$3 per GPU-hour ÷ 3,600 s) ≈ $0.0017, plus ~20% for safety models, upscaling and storage
Storage~2 TB/day, ~700 TB/yearStore high-quality WebP ~400 KB per 1024² image (PNG would be ~1.5 MB) × 5M = 2 TB/day. Free-tier images not saved expire after 30 days, cutting the yearly total roughly in half
Model weights7-15 GB per modelA ~3.5B-parameter diffusion model in fp16 ≈ 7 GB, plus text encoders and VAE. Loading from S3 at ~1 GB/s ≈ 10-15 s; from local NVMe ~3 s; LoRA adapters are 50-300 MB
Progress connections~30k concurrent SSE streamsPeak 175 images/s ÷ 4 ≈ 44 jobs/s × ~40 s average wait+generate (Little's law) ≈ 1,800 active jobs; many users keep the page open, so plan for ~30k idle streams

3 API

EndpointWhat it does
POST/v1/generationsBody {prompt, negative_prompt, model, aspect, n, seed?, style?}, header Idempotency-Key. Runs prompt safety, reserves credits, enqueues. Returns 202 {job_id, queue_position}.
SSE/v1/generations/{job_id}/eventsStreams queued(position) → started → progress(step, preview_url) → completed(image_urls) or blocked/failed. Resumable with Last-Event-ID.
GET/v1/generations/{job_id}Polling fallback for clients that cannot hold SSE (some mobile networks).
POST/v1/images/{image_id}/upscaleEnqueues an upscale job on a cheaper GPU pool.
POST/v1/images/{image_id}/editInpainting with an uploaded mask (pre-signed S3 upload) and a new prompt.
DELETE/v1/generations/{job_id}Cancel a queued or running job; credits released if no image was delivered.
GET/v1/me/images?cursor=Gallery, newest first, with CDN URLs.

4 Data model

jobsPostgres (partitioned by month)
job_id (PK, ULID)user_ididempotency_key (unique per user)tiermodellora_idparams (json)prompt_hashstatus (queued|running|post|done|blocked|failed|cancelled)attemptsworker_idlease_untilcredits_reservedcreated_atstarted_atfinished_at

Source of truth for job state and billing. Index (user_id, created_at) for history; unique (user_id, idempotency_key) makes retries of POST safe.

queuesRedis (sorted sets per model pool and priority)
q:{model}:{lane} → member job_idscore = enqueue time (adjusted by fairness); inflight:{user} counter

Fast pop by priority. Redis is not the source of truth: a sweeper re-enqueues jobs whose lease expired, from Postgres.

credit_ledgerPostgres
entry_iduser_idjob_idtype (reserve|commit|release|grant)amountcreated_at; balance view

Double-entry style, append-only. Reserve at enqueue, commit on delivery, release on failure; unique (job_id, type) prevents double charge.

imagesPostgres metadata + S3 objects
image_idjob_iduser_ids3_keywidthheightseedsafety_labels (json)c2pa_manifest_refvisibilityexpires_at

Objects in S3 under user/date/image_id.webp, served through CDN with signed URLs for private images.

progressRedis Pub/Sub
channel job:{job_id} → events {steppreview_key}

Ephemeral; the SSE service subscribes per connection. Final state also written to Postgres so a reconnect can recover.

model_registryPostgres + S3
model_idversionweights_s3_prefixgpu_mem_gbpooldefault_stepsenabled

Workers read it at start; rollout of a new model version is a registry change plus a new worker pool.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAPOST /generationscheck promptjob + reserve creditsenqueuepull by modelstep previewsraw imagesstore WebP + C2PAdone / blockedSSE eventssubscribeload imagesorigin fetchload weightsWeb / mobile appAPI gatewayCDN (images)Generation APIPrompt safety modelProgress (SSE) servicePriority queues(Redis)Output safety + encodeGPU workers(diffusion)Postgres jobs +creditsS3 images + weightsRedis Pub/Sub progress
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Generate four images

  1. 1The app sends POST /generations with an Idempotency-Key; the gateway authenticates and applies per-user rate limits.
  2. 2The API runs the prompt safety model (a small multilingual text classifier, ~20 ms) plus blocklists; a disallowed prompt is rejected at once and costs no GPU time.
  3. 3In one Postgres transaction the API inserts the job (status queued) and a 'reserve' ledger entry; if the user lacks credits it returns 402.
  4. 4The job id is added to the Redis queue for its model pool and lane (paid-fast or free), and the API returns 202 with job_id; the app opens the SSE stream.
  5. 5A GPU worker with that model already loaded pops the next job (respecting the user's concurrency cap), sets a 60 s lease in Postgres and starts denoising.
  6. 6Every 5 steps it decodes a low-resolution preview with a tiny decoder, uploads it, and publishes a progress event; the SSE service forwards it to the browser.
  7. 7After the final step the raw images go to post-processing: the image safety classifier and known-CSAM hash matching run, then images are encoded to WebP with C2PA metadata and an invisible watermark.
  8. 8Safe images are written to S3; the job becomes done and the ledger 'commit' entry is written; blocked images are discarded and the credit released.
  9. 9The SSE stream sends completed with signed CDN URLs; the browser loads images from the CDN.

GPU node dies mid-job

  1. 1The worker stops renewing its lease; after 60 s the sweeper sees lease_until in the past.
  2. 2The sweeper increments attempts and re-enqueues the job at the front of its lane (it has already waited).
  3. 3Another worker picks it up and regenerates with the same seed, so the user sees the same result they would have seen.
  4. 4After 3 failed attempts the job is marked failed, the reserved credits are released and the user gets an error with a retry button.

7 Deep dives

Scheduling GPUs: pull model, batching and model affinity

GPU workers should pull work, not be pushed work: a worker asks for a job only when it has free capacity, so slow jobs never pile up on one GPU. Each worker serves one model (or one family) that stays loaded in GPU memory, because loading 7-15 GB of weights takes 3-15 s and would double the latency of every job. So queues are per model pool: q:sdxl:paid, q:sdxl:free, q:upscaler, and the autoscaler sizes each pool from its queue depth and wait time, not from CPU. Batching improves throughput: four images of the same prompt run as one batch of 4, and the worker can also merge small jobs with the same model and resolution, raising throughput per GPU by roughly 30-50% at the cost of a little latency. Mixed resolutions do not batch well, so standard aspect ratios are bucketed. Style LoRAs (50-300 MB) are cached in GPU or host memory with LRU and loaded per job, and the scheduler prefers a worker that already holds the LoRA. Rare models get a small warm pool or scale to zero with a visible 'warming up' state. Cold start is cut by baking weights into the node image or local NVMe cache instead of pulling from S3 on every start.

Fairness and admission under spikes

On a festival evening demand can exceed capacity for hours, and you cannot add 300 GPUs in minutes. The design must decide who waits. Paid users go into a fast lane that workers always check first, but with a weighted share (for example 80/20) so the free lane is never fully starved. Per-user concurrency caps (free: 1 job in flight, paid: 4-12) stop one user with a script from filling the queue; jobs above the cap wait in a per-user holding list. Inside a lane, ordering by enqueue time is fine, but a user's 10th job should not jump ahead of another user's 1st, so the score adds a small penalty per in-flight job. Admission control protects latency: if the estimated wait for the free lane passes ~3 minutes, new free requests get a clear 'busy, try later' or are offered a cheaper, faster distilled model, instead of joining a queue they will abandon. Abandoned jobs waste GPUs, so when an SSE client disconnects and does not return within 30 s, queued jobs are cancelled. Showing an honest queue position keeps users calm and reduces retries that would add load.

Safety pipeline on both sides of the GPU

Check the prompt first, because blocking before generation saves GPU money. Use layers: exact and fuzzy blocklists, a fast text classifier trained for each policy area (sexual content involving minors, real-person sexual content, extremist symbols, graphic violence), and for ambiguous prompts a small LLM that also catches obfuscation ('n.u.d.e', transliterated Hindi). The prompt check cannot catch everything, so every output image goes through an image classifier for nudity, gore and minors, plus hash matching (perceptual hashes such as PDQ) against industry lists of known abusive images, before anything is stored or shown. Thresholds are set per policy: for child safety, block at low confidence and accept false positives; for mild violence, allow more. Blocked outputs are never written to user storage; a hash and labels are logged for review, and confirmed CSAM is reported as the law requires. Add provenance: C2PA manifest in the file metadata and an invisible watermark that survives resizing and compression, because metadata is stripped by most social apps. Regulators, including in India, are moving toward requiring labels on synthetic media, so build it in from day one.

Credits that are never charged twice

Money and retries interact badly. The client may resend POST /generations on a flaky Jio or Airtel connection, workers may crash and retry, and a job may be blocked after generation. Use a reservation pattern with an append-only ledger. The POST carries an Idempotency-Key; a unique index on (user_id, idempotency_key) makes the second request return the first job. Enqueue writes a 'reserve' entry in the same transaction as the job row, so the balance shown already excludes in-flight work and a user cannot start 50 jobs on 10 credits. When images are delivered, a 'commit' entry is written; on failure, cancel or a safety block, a 'release' entry. A unique constraint on (job_id, type) makes each transition happen at most once even if two workers race after a lease expiry. A nightly reconciliation finds reservations older than an hour without a commit or release and releases them. The GPU retry itself is safe because generation has no external side effect until the final write, and the same seed reproduces the same image.

8 Trade-offs

Async job + SSE vs one long HTTP request

A synchronous request that holds for 10-60 s ties up connections, breaks on mobile network switches and makes retries dangerous. An async job with SSE progress survives reconnects (Last-Event-ID or polling), lets you show queue position and supports cancellation. The cost is more moving parts: a queue, a job table and a progress channel.

Redis queues vs Kafka vs SQS

Kafka is built for ordered logs, not per-job priority, cancellation and visibility timeouts. SQS gives leases but no priority within a queue and no fairness logic. Redis sorted sets give priority and custom fairness scoring with sub-millisecond ops; durability comes from Postgres as the source of truth plus a sweeper that rebuilds the queue.

Own GPUs vs cloud GPUs vs hosted model APIs

A hosted image API is fastest to launch and has no fleet to run, but cost per image is several times higher and you cannot customise models. Reserved cloud GPUs give the lowest cost at a steady base load; on-demand or spot capacity covers peaks. Image jobs are short and restartable, so spot interruptions only cost a retry.

Image quality vs speed

Distilled or few-step models generate in under a second but with less detail. Offering 'fast draft' (4-8 steps) and 'final' (30 steps, plus upscale) lets users explore cheaply and spend GPU time only on images they like.

9 Bottlenecks

Peak demand above fleet size

At 10× load the queue grows without bound. Fix with admission control and honest wait estimates, cheaper fast models as a fallback, scheduled pre-scaling before known peaks (Diwali, New Year), and spot capacity across several regions.

Storage and CDN egress

Hundreds of TB per year and heavy sharing traffic. Expire unsaved free-tier images, store WebP not PNG, generate thumbnails for the gallery, and keep popular shared images hot in the CDN.

10 Failure handling

GPU worker crash or spot reclaim

Leases expire and the sweeper re-enqueues the job; the same seed reproduces the image; credits stay reserved until delivery. Workers handle SIGTERM by finishing the current batch if it can complete within the 2-minute spot warning, otherwise they release the lease immediately.

Safety classifier service down

Fail closed: no image is delivered without an output check. Jobs wait in the post-processing queue and users see 'finishing up'. This is a conscious choice: a few minutes of delay is far cheaper than serving one illegal image.

Redis queue lost

Postgres has every queued job; the sweeper rebuilds the sorted sets from jobs with status queued, ordered by created_at. Duplicate pops are harmless because the lease update in Postgres is a conditional write that only one worker can win.

11 What impresses

  • Computes GPU fleet size and cost per image from GPU-seconds before drawing boxes
  • Explains why workers pull jobs per model pool and why weights must stay warm
  • Designs fairness (weighted lanes, per-user caps) and admission control for spikes rather than an unbounded queue
  • Runs safety on both prompt and output, fails closed on the output check, and justifies per-policy thresholds
  • Uses a reserve/commit/release credit ledger so retries never double-charge
  • Mentions provenance labelling and why metadata alone is not enough

12 Mistakes

  • Designing a synchronous REST call that waits 30 s for the image
  • Using CPU-based autoscaling for GPU workers instead of queue depth and wait time
  • Loading model weights per request
  • Checking only the prompt and trusting the output
  • Charging credits at submit time with no release on failure
  • One global FIFO queue where one heavy user can starve everyone

13 Follow-ups

Q1How would you add image-to-video generation that takes 2 minutes per clip?
Q2How do you roll out a new model version without hurting latency?
Q3A user complains the same prompt gives different images. When should results be reproducible?
Q4How would you cache results to save GPU time, and when is caching wrong here?
Q5How do you handle prompts in Hindi, Tamil or Hinglish for both generation and safety?
Q6How would you price a 'fast hour' plan based on your cost numbers?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free