Every uploaded image needs six sizes and the thumbnail has to appear immediately. What runs before you return, and what does not?
Thumbnail generation system design should return one displayable image immediately while every durable variant is produced asynchronously. Persist the original, write metadata, show a client-side preview or first small thumbnail, and expose variant readiness so the UI never asks for an image size that is not ready.
What the interviewer is scoring
- Does the candidate build a latency budget for the request before deciding what belongs in it
- Whether "immediately" is met without a server render at all, using the bytes the client already holds
- That the variant set is questioned, so rarely-requested sizes are derived lazily rather than precomputed
- Can they describe what the API returns while only some variants exist
- Whether a render that fails permanently has a defined outcome rather than a retry loop
Answer
Short answer
Persist the original first, return a preview or smallest thumbnail immediately, and generate the remaining image sizes through idempotent background jobs that publish variant readiness.
Build the budget first, then decide what fits
Put numbers against the premise before choosing an architecture. Say a 4MB photograph takes 300 milliseconds to decode, resize and re-encode. Six sizes done in sequence is 1.8 seconds of processing, on top of receiving the bytes and writing them somewhere. Add a modern variant format and two pixel densities and you are not rendering six images. You are rendering twenty-four. That is over seven seconds of work.
Now compare that with the budget. An upload interaction that returns in a few hundred milliseconds feels finished; one that returns in three seconds feels broken, and the user has usually navigated away. So the question is not whether to make the work asynchronous. It is which single piece cannot be, and the answer is smaller than most people expect.
The instant thumbnail should not involve your renderer
The client already has the file. It selected it from a camera roll or a filesystem, it can decode it, and every browser and mobile platform can draw a scaled copy of an image it holds. So render the preview locally and display it the moment the user picks the file, before a single byte has been uploaded.
This is the answer that reframes the question, because it makes "immediately" genuinely immediate rather than merely fast. Nothing on the server is on the critical path for what the user sees. The local preview is replaced by the real URL when the server-side variant exists, and if the two differ slightly in sharpening or colour profile, nobody notices a swap in an image they are already looking at.
Where a client-side preview is unavailable, say so and take the cheapest server-side option. Render exactly one size in-band, the one the next screen displays, and queue the rest. One render of 300 milliseconds inside the request is defensible. Six is not.
What crosses the boundary
The split is worth stating as a list, because the interviewer is checking that the synchronous side contains only things that would be wrong to defer.
| Runs before you return | Runs after |
|---|---|
| Store the original bytes | Render the remaining sizes |
| Write the metadata row with its state | Produce alternative formats |
| Validate size, type and quota | Extract dimensions, orientation and any perceptual hash |
| Return the identifier and the URLs that will exist | Strip metadata, scan content, update search indexes |
The last row on the left is the one that gets missed. If you return URLs for variants that do not exist yet, the client requests them and gets a 404, and somebody adds a retry loop that hides the design flaw behind traffic. Return the state alongside the identifier, so the client knows which variants are ready and what to display in the meantime.
Precomputing all twenty-four is usually the wrong default
Six sizes multiplied by two formats and two densities gives twenty-four variants per upload. Ask which of them anybody fetches. In most products a handful of sizes carry nearly all the traffic and the rest exist because a page needed them once. If two thirds of your variants are never requested, two thirds of your rendering cost and storage is going into images no human will see.
The alternative is to render on first request. A miss at the edge hits a small service that produces the variant, stores it and returns it, so the first requester pays the render and everybody after them gets a cache hit. That converts a fixed cost per upload into a cost per distinct variant genuinely used.
It comes with a rule you have to enforce: the set of allowed transformations must be a closed list, signed or otherwise validated. An open resize endpoint that renders whatever dimensions appear in the query string is a way for anyone to make you render ten thousand distinct images from one file. Constrain the parameters, and the lazy path is both cheaper and safe.
Precompute what the critical path needs and derive the rest lazily. That is the shape most mature pipelines converge on, and saying it directly reads as experience rather than as a compromise.
Jobs fail, and a permanently failing job needs a decision
Every asynchronous design owes an answer for the job that never succeeds. A file that decodes as a valid JPEG on the client and crashes your library. An animated image nobody thought about. A 30,000-pixel-wide photograph whose decode wants more memory than the container has.
Backoff and retry handle the transient cases. The terminal case is what matters: after some attempts the job moves to a dead-letter destination, the metadata row records that this variant will not exist, and the product shows a defined fallback rather than a spinner that never resolves. Deciding that a spinner is not a valid end state is a design decision, and it is the one that separates a pipeline from a pile of workers.
Make the job idempotent while you are there, keyed by the original's identifier and the exact transform. A queue that guarantees at-least-once delivery will deliver twice, and the second attempt should notice the output already exists and stop. Without that, a redelivery storm turns into a rendering storm at the worst moment.
# The key is derived, not generated, so a redelivered job is a no-op
# rather than a second render of identical bytes.
key = f"{image_id}/w{width}-h{height}-{fmt}"
if store.exists(key):
return # already done; a duplicate delivery costs nothing
store.put(key, render(original, width, height, fmt))
The coupling that survives making it asynchronous
Even with one in-band render, your upload endpoint now depends on the renderer being available. When the renderer is unhealthy, uploads slow down or fail, and the outage is reported as "I cannot post photos" rather than "thumbnails are late". That is a worse incident than the one you were avoiding.
Give the in-band render a hard timeout and a fallback. If it does not finish inside the budget, abandon it, queue the work, and return the pending state that the client already knows how to display. Uploads then keep working through a renderer outage, degraded in exactly one visible way, which is what a strong candidate names as the goal: "The only thing that should ever fail because of the image pipeline is an image, never the upload."
The synchronous part of an upload is the smallest set of steps that makes the response honest. Everything else is a job, and the API's real job is to tell the client which variants exist right now.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- The renderer is down for twenty minutes. What does a user who uploads during that window see, and what do they see afterwards?
- How do you avoid rendering the same variant twice when the job is delivered twice?
- Which of the six sizes would you drop entirely, and what measurement would justify dropping it?
- A designer adds a seventh size for a page that launches on Friday. What has to happen before Friday?
Related questions
- Two clients open the same record, both edit it, and the second save silently overwrites the first. How would you use ETags to turn that lost update into something the client can see and handle?mediumAlso on api-design4 min
- A job queue is backed up four hours and half the jobs are now pointless. What do you drain, and what do you drop?hardAlso on job-queue6 min
- Your Spark batch pipeline now needs results inside a minute. Do you move to Flink, and what breaks if you do?hardAlso on latency-budget6 min
- How much customisation should a shared component expose before it stops being a design system?hardAlso on api-design4 min