Most AI image generators look like a prompt box with a Generate button. That is also how my first version started.
But once real users entered the workflow, the difficult problems appeared somewhere else: browser refreshes, external task IDs, reference images, partial failures, credit refunds, private assets, and public artwork moderation.
While building Magggic, I learned that an AI image generator is less like a form submission and more like a small distributed job system. This article covers the decisions that made that workflow more reliable.
The code samples below are intentionally simplified. The important part is the shape of the workflow, not a specific database or image provider.
The prompt box is only the beginning
A synchronous prototype is easy to imagine:
const images = await provider.generate(prompt);
return images;
That version works until the request takes a minute, the provider times out, one of four requested images fails, or the user refreshes the page.
The production workflow I needed looked more like this:
Prompt + references
↓
Create a local queued task
↓
Charge credits with an idempotency key
↓
Submit work to the image provider
↓
Persist every completed output immediately
↓
Finalize the task and refund failed outputs
↓
Keep the result private until the user publishes it
The provider request is only one step. The local task is the source of truth for what the user sees.
1. Persist the task before calling the provider
The first important decision was to create a generation record before making the external API request.
A generation stores the information needed to reconstruct the job:
type Generation = {
id: string;
userId: string;
idempotencyKey: string;
prompt: string;
referenceImages: string[];
model: string;
ratio: string;
resolution: string;
count: number;
cost: number;
status: "queued" | "generating" | "completed" | "failed";
outputs: string[];
providerRequestIds: string[];
failureReason: string | null;
};
Persisting first gives the UI a stable generation ID immediately. The browser can refresh, reconnect, or open the task from history without depending on the original HTTP connection.
It also gives operational errors a useful identity. “The provider timed out” is difficult to investigate. “Generation abc123 timed out after provider task xyz789” is actionable.
2. Make state transitions explicit
Generation states should not be decorative labels. They should control which writes are valid.
For example, a worker should only start a queued task:
const started = await updateGeneration({
id: generationId,
expectedStatus: "queued",
nextStatus: "generating",
});
if (!started) return;
Likewise, an output should only be attached while the task is generating. Final completion should fail loudly if the task is already in an unexpected state.
This avoids two common problems:
- two workers processing the same task;
- a late provider response overwriting a task that has already failed or completed.
I prefer conditional updates over reading a status and then updating it later. The condition and the transition happen together, which makes races much easier to reason about.
3. Persist each output as soon as it succeeds
When a user requests four images, the provider may effectively give you four independent outcomes. Waiting for every result before saving anything creates unnecessary risk.
The workflow now persists each completed image as it arrives:
async function persistCompletedOutput(output: ProviderOutput) {
const storedUrl = await storeImage(output.bytes, output.contentType);
await appendGenerationOutput({
generationId,
expectedStatus: "generating",
outputIndex: output.index,
storedUrl,
externalTaskId: output.taskId,
});
}
This produces three useful properties:
- A successful image is not lost because another image failed later.
- Progress is based on real persisted outputs, not an invented percentage.
- The UI can show completed images while the remaining outputs are still running.
There is one uncomfortable edge case: storage succeeds but the database write fails. In that case, the stored object should be deleted or recorded for cleanup. Otherwise every database outage can leave orphaned files behind.
4. Treat partial success as a real outcome
The easiest implementation treats a batch as either successful or failed. That is also the least useful implementation for the user.
Suppose the user requests four images:
- three images complete successfully;
- one provider task fails;
- the total generation price was 32 credits.
Marking the whole task as failed hides three valid outputs. Marking it as fully successful charges the user for something they did not receive.
The better result is:
status: completed
successful outputs: 3
failed outputs: 1
credits refunded: 8
failure note: one output could not be completed
In simplified form:
const failedCount = results.filter(isRejected).length;
const refundAmount = failedCount * unitCost;
await transaction(async (tx) => {
await refundCredits(tx, generationId, refundAmount);
await completeGeneration(tx, generationId, {
outputs: completedOutputs,
failureReason: failedCount ? buildPartialFailureMessage() : null,
});
});
The final state can still be completed because usable outputs exist. The partial-failure message explains why fewer images were returned and how many credits were refunded.
5. Credits need a ledger, not only a balance
At first, a credit system looks like one integer on the user record:
user.credits -= cost;
That stops being sufficient as soon as you need to answer basic support questions:
- Why did this balance change?
- Was this generation charged twice?
- Was a failed task refunded?
- Which type of credits were consumed?
- What was the balance after the operation?
The approach I settled on keeps both current balances and an append-only ledger.
type CreditLedgerEntry = {
userId: string;
kind: "grant" | "spend" | "refund" | "expire" | "adjustment";
amount: number;
balanceAfter: number;
referenceType: "generation" | "payment" | null;
referenceId: string | null;
idempotencyKey: string;
};
Every spend or refund has a unique idempotency key. Retrying the same completion handler cannot create a second refund.
This is especially important with asynchronous providers. Network timeouts encourage retries, and retries are exactly where accidental double charges and double refunds appear.
6. Prompt remix and reference reuse are different operations
An image detail page often needs two actions that look similar but have different semantics:
- Create similar reuses the prompt and generation parameters.
- Use as reference sends the current image back as a visual input.
Combining them into one generic “Remix” action makes the data ambiguous. A future reviewer cannot tell whether the result came from text alone or from another image.
I now store reference images on the generation task and carry a separate reference-image status onto a published artwork. For manually imported artwork, the status can be used, not_used, or unknown.
This provenance matters for three reasons:
- The UI can explain what another user needs to reproduce the result.
- Public pages do not need to expose the original private reference file.
- Moderation and support can distinguish text-to-image from image-guided generation.
7. A generated asset is not automatically a public artwork
Another early design mistake is treating every generated image as public content.
Generation outputs belong in the user's private assets and history. Publishing is a separate action with a separate lifecycle:
type ArtworkStatus =
| "pending"
| "published"
| "rejected"
| "withdrawn"
| "hidden";
The artwork record can reference a generation output, but it also owns public metadata such as category, tags, publication time, moderation state, and the public image URL.
This separation keeps several product rules simple:
- Refreshing generation history never publishes anything.
- A user can submit only the output they choose.
- Moderation can reject or hide a public artwork without deleting the private generation.
- Public
LatestandPopularfeeds operate on approved artwork, not raw generation history.
It also prevents a privacy problem: a successful provider response should never equal automatic consent to publish.
8. Design failure states as part of the product
Failure UI is often added at the end, but it is part of the core workflow.
A useful failed task should tell the user:
- what happened in plain language;
- whether any outputs were saved;
- whether credits were refunded;
- whether retrying will create a new task;
- where the failed attempt remains visible.
I keep failed records in generation history instead of removing them. Retrying creates a new history item rather than rewriting the old one. That gives both the user and the support team a truthful timeline.
The same rule applies to progress. If the provider only gives a queued state and a completed result, the UI should show those real states. A beautiful fake “73%” progress bar is still fake.
What I would do from the first day now
If I were starting the generation workflow again, I would make these decisions before polishing the prompt box:
- Create a persistent local task before calling any provider.
- Use conditional state transitions for workers and result writes.
- Persist each output independently.
- Model partial success instead of flattening every batch into pass or fail.
- Record credits in an idempotent ledger.
- Keep text remix and image reference reuse separate.
- Separate private generated assets from moderated public artwork.
None of these ideas is limited to image generation. Any AI product that combines asynchronous providers, paid usage, and persistent outputs will eventually face the same questions.
The prompt box is the visible part. Reliability is the product underneath it.
I would be interested to hear how other developers model partial AI task failures, provider retries, and idempotent refunds.
Top comments (0)