Design an AI image generation service
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
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
3 API
| Endpoint | What it does |
|---|---|
POST/v1/generations | Body {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}/events | Streams 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}/upscale | Enqueues an upscale job on a cheaper GPU pool. |
POST/v1/images/{image_id}/edit | Inpainting 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_atSource 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} counterFast pop by priority. Redis is not the source of truth: a sweeper re-enqueues jobs whose lease expired, from Postgres.
credit_ledgerPostgresentry_iduser_idjob_idtype (reserve|commit|release|grant)amountcreated_at; balance viewDouble-entry style, append-only. Reserve at enqueue, commit on delivery, release on failure; unique (job_id, type) prevents double charge.
imagesPostgres metadata + S3 objectsimage_idjob_iduser_ids3_keywidthheightseedsafety_labels (json)c2pa_manifest_refvisibilityexpires_atObjects in S3 under user/date/image_id.webp, served through CDN with signed URLs for private images.
progressRedis Pub/Subchannel 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 + S3model_idversionweights_s3_prefixgpu_mem_gbpooldefault_stepsenabledWorkers read it at start; rollout of a new model version is a registry change plus a new worker pool.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Generate four images
- 1The app sends POST /generations with an Idempotency-Key; the gateway authenticates and applies per-user rate limits.
- 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.
- 3In one Postgres transaction the API inserts the job (status queued) and a 'reserve' ledger entry; if the user lacks credits it returns 402.
- 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.
- 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.
- 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.
- 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.
- 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.
- 9The SSE stream sends completed with signed CDN URLs; the browser loads images from the CDN.
GPU node dies mid-job
- 1The worker stops renewing its lease; after 60 s the sweeper sees lease_until in the past.
- 2The sweeper increments attempts and re-enqueues the job at the front of its lane (it has already waited).
- 3Another worker picks it up and regenerates with the same seed, so the user sees the same result they would have seen.
- 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
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.