AI image generation looks synchronous in a demo: send a prompt, wait, and render an image. The production experience is different. Providers can be slow, browsers refresh, callbacks arrive twice, temporary URLs expire, and one output may fail while the others succeed.
These questions came up while working on creator tools such as ClickCrit, where one source image can lead to several visual concepts. The product-specific implementation is private, so this article uses a deliberately generic reference architecture. The goal is to explain reusable engineering decisions without describing any private provider, schema, prompt, billing, or infrastructure details.
The Core Design Principle
The lifetime of an AI task should not be tied to the lifetime of the browser request that started it.
A fragile implementation usually looks like this:
Browser request
-> call the image provider several times
-> wait for every result
-> return everything together
This works until one request is slow or interrupted. If the browser retries, the application may submit the same work again. If one output fails, the whole response may be lost. If the request times out after the provider accepted the work, the server may not know whether retrying is safe.
A more resilient design makes the initial request short:
Browser
-> create a durable batch
-> receive a batch ID
-> observe progress
Background execution
-> process each output independently
-> reconcile external status
-> persist valid results
-> update the batch
The database stores the workflow. HTTP requests only move it from one valid state to another.
1. Represent the Batch and Its Outputs Separately
When a user asks for several images, model the request as one parent batch with several child items.
type Batch = {
id: string;
state: "queued" | "running" | "complete" | "failed";
};
type BatchItem = {
id: string;
batchId: string;
state: "queued" | "running" | "complete" | "failed";
resultId?: string;
};
The names are illustrative. The important distinction is responsibility:
- The batch represents one user action.
- Each item represents one independently executable result.
- The batch state is derived from its items.
This model allows useful partial completion. If three images are ready and one is still running, the interface can show the three available results instead of hiding them behind the slowest task.
It also makes retries more precise. The system can retry one failed item rather than repeating the entire batch.
2. Make Creation Idempotent
Mobile networks, impatient users, browser extensions, and client-side retry libraries can all repeat a request. A repeated request must not silently create a second batch.
Give every user action an idempotency key:
type CreateBatchInput = {
sourceAssetId: string;
idempotencyKey: string;
};
Store the key with the user identity and enforce uniqueness at the persistence layer. Application-level checks alone still leave a race window between “does this exist?” and “create it.”
The expected behavior is simple:
First request with key A -> create and return batch 123
Second request with key A -> return existing batch 123
Request with key B -> create a new batch
If the workflow reserves a limited resource—credits, quota, or capacity—perform the reservation and batch creation atomically. Either both happen or neither happens. The specific commercial rules belong to the product, but the consistency requirement is universal.
3. Return Early from the Next.js Endpoint
The request handler should authenticate the user, validate the input, create the durable batch, and return its identifier.
export async function POST(request: Request) {
const user = await authenticate(request);
const input = await validateInput(request);
const batch = await createBatch({
userId: user.id,
sourceAssetId: input.sourceAssetId,
idempotencyKey: input.idempotencyKey,
});
return Response.json(
{ id: batch.id, state: batch.state },
{ status: 201 },
);
}
Next.js Route Handlers use the standard Web Request and Response APIs and are a natural boundary for this operation.
Do not depend on an unawaited promise continuing after a serverless response. Dispatch may happen through a queue, a follow-up endpoint, or a scheduled recovery process. Whichever mechanism you choose, the durable batch—not an in-memory promise—must remain the source of truth.
4. Claim Work Atomically
More than one worker may discover the same queued item. If both submit it to an external provider, the application creates duplicate work and may pay twice.
The worker therefore needs a claim operation, not just a read operation:
1. Find an eligible item.
2. Lock or conditionally update it.
3. Record that execution has started.
4. Return the item to exactly one worker.
In PostgreSQL, row locking and SKIP LOCKED are common building blocks for queue-like consumers. The PostgreSQL feature documentation describes how locked rows can be skipped instead of blocking other consumers.
For recovery, pair the claim with a lease or deadline. If the worker disappears, another process can inspect the abandoned item after that deadline. The duration and retry policy are operational details you should tune for your own provider.
5. Distinguish Failure from Uncertainty
External API calls have an awkward failure mode:
- The provider accepts the task.
- The network fails before your application receives or stores the response.
- Your application cannot tell whether the task exists.
This is not the same as a confirmed rejection.
A generic retry can duplicate a paid task. A safer state machine distinguishes at least these meanings:
- Confirmed submission failure: no external task was created.
- Confirmed submission success: the external task identifier was stored.
- Unknown submission outcome: the provider may have accepted the task.
When the outcome is unknown, delay resubmission and send the item through a reconciliation path. If the external provider accepts client-generated idempotency keys, use them. If not, preserve enough attempt metadata to investigate or safely expire uncertain work.
Distributed systems become more reliable when “I do not know” is represented honestly instead of being flattened into “failed.”
6. Let Callbacks and Polling Converge
Provider callbacks are efficient, but they can be delayed, duplicated, or missed. Polling is less elegant, but it repairs missing callbacks. The two paths should call the same idempotent reconciliation function.
async function reconcile(externalTaskId: string) {
const item = await findActiveItem(externalTaskId);
if (!item) return;
const external = await provider.read(externalTaskId);
if (external.state === "pending") return;
if (external.state === "failed") {
await recordFailure(item.id);
return;
}
const result = await validateAndStore(external.resultUrl);
await recordSuccess(item.id, result.id);
}
The function must tolerate repeated calls. Two callbacks, or a callback racing with a poll, should still produce one terminal transition.
Authenticate callback requests using the provider's documented signature scheme or an application-controlled secret. Do not trust a task identifier by itself.
7. Own the Final Asset
An external result URL is not a durable product asset. It may expire, change access rules, or disappear after the provider's retention period.
Before marking an item complete:
- Download the result with a timeout.
- Check the response status and allowlisted content type.
- Enforce a maximum size.
- Decode the file and validate basic dimensions.
- Store it in application-controlled storage.
- Save only the durable asset reference as the result.
Keep user uploads and generated files private unless the product explicitly makes them public. Supabase provides useful background on Storage access control and signed URLs for private assets.
Do not let the browser request arbitrary remote URLs through a privileged server-side downloader. The result URL should come from a trusted provider response, and the downloader should still enforce protocol, redirect, size, and content checks appropriate to the application.
8. Design the Interface for Partial Results
Backend reliability and user experience are connected. A batch interface should make independent progress visible.
Useful states include:
- A batch-level progress summary.
- A placeholder for every expected result.
- Completed items shown as soon as they are ready.
- A clear retry action for a failed item.
- An explanation when the system is still checking provider status.
- Refresh recovery using the durable batch ID.
Avoid treating the entire batch as failed just because one item failed. Also avoid an endless spinner when the system has reached a terminal state. The UI should reflect the same state model used by the backend.
9. Keep Privileged Boundaries on the Server
The browser should never receive provider credentials, storage administrator keys, or database service credentials.
Route-level authorization is necessary, but database and storage rules should also enforce ownership. With Supabase, enable Row Level Security on exposed tables and scope policies to the authenticated user.
A practical security checklist:
- Authenticate every batch read and mutation.
- Verify that the source asset belongs to the current user.
- Validate input shape, length, and file type.
- Keep provider credentials server-only.
- Authenticate callbacks.
- Use short-lived signed URLs for private assets.
- Avoid returning raw provider errors to the browser.
- Prevent one user from requesting another user's result.
Privileged server code may bypass database policies, so it must perform explicit ownership checks before returning records or signed URLs.
10. Test Failure Transitions
The happy path is not where this architecture earns its keep. Test the points at which responsibility changes between the browser, database, worker, provider, and storage system.
| Scenario | Expected behavior |
|---|---|
| The same create request arrives twice | One batch is returned |
| Two workers claim simultaneously | One worker owns each item |
| The callback arrives twice | One terminal transition occurs |
| The callback never arrives | Polling can still finish the item |
| Submission outcome is unknown | No immediate blind resubmission |
| The result is not a valid image | It is rejected before completion |
| One output fails | Other completed outputs remain available |
| The browser refreshes | Progress resumes from the batch ID |
| A user requests another user's asset | Access is denied |
| A worker disappears after claiming | Recovery can revisit the item |
Unit tests are useful for provider-response mapping and state-reducer logic. Integration tests are more valuable for concurrency, unique constraints, ownership policies, and atomic transitions.
A Useful Mental Model
Multi-image generation is not one slow function call. It is a small workflow whose outputs happen to be images.
The reusable pattern is:
- Create durable intent before doing external work.
- Make repeated user requests idempotent.
- Process outputs independently.
- Represent uncertainty instead of retrying blindly.
- Make callbacks and polling converge on the same logic.
- Persist and validate external assets before reporting success.
- Expose partial progress in the interface.
- Test every boundary where a request can be repeated or interrupted.
You do not necessarily need a large distributed system to apply these ideas. A Next.js application and a relational database can go a long way when states, ownership, and transaction boundaries are explicit.
For a creator-facing example of why several distinct outputs are useful, see this screenshot-first Roblox thumbnail workflow. The engineering lesson is broader: once AI work becomes asynchronous and billable, durability is part of the product experience.
Top comments (0)