Disclosure: I used AI to help organize and edit this article. The implementation details and engineering decisions come from my own project, and I reviewed the final draft for accuracy.
Letting a visitor try an AI image tool before creating an account sounds simple. Give them a text box, call an image API, and show the result.
That version works until real traffic arrives.
Image generation has a direct cost. Requests take long enough for users to refresh, double-click, or close the tab. Providers fail. Workers restart. A person can switch between anonymous and signed-in sessions. If the free path is only a boolean such as hasUsedTrial, it will eventually charge twice, run twice, or become easy to abuse.
I ran into these problems while building the guest flow behind PicEditor, a browser-based AI image editor. The useful lesson was that a free generation should not be treated like a coupon. It should be treated like a small distributed job with a reservation, a lifecycle, and a cost owner.
The request needs a lifecycle
The first design question was not “Is this user free?” It was “What state is this request in?”
I ended up using a short list of states that correspond to things the server can actually prove:
reserved -> pending -> processing -> completed
\-> failed
The browser can display queued as part of the user experience, but the source of truth remains on the server. I deliberately avoided fake progress percentages and messages such as “analyzing your idea” when no such step exists.
This matters more than it may seem. If a visitor refreshes the page after submission, the client should reconnect to the same job instead of creating another provider request. If the same anonymous identity submits again while a job is active, the server should return the existing job or reject the duplicate.
Reserve before calling the provider
Before an image request reaches the model provider, the server performs several checks:
- Is guest generation currently enabled?
- Did the browser complete the anti-bot challenge?
- Is this identity or network already over its free allowance?
- Does the identity already have a generation in progress?
- Is there enough room in the daily provider-cost budget?
The important part is the order. I do not want two concurrent requests to both read “budget available” and then both spend it.
The simplified flow looks like this:
const reservation = await reserveIdentitySlot(identity);
try {
await reserveCostBudget(estimatedProviderCost);
} catch (error) {
await restoreIdentitySlot(reservation);
throw error;
}
The identity claim uses a conflict-safe database write. The budget uses a conditional update that succeeds only while the new reserved total remains under the cap. If the second operation fails, the first reservation is restored.
It is not a magical global transaction. It is a small reservation protocol with an explicit compensation step. That distinction made the failure cases much easier to reason about.
Keep access policy separate from billing
Another decision that helped was separating how a request is allowed to run from how it is paid for.
The generation record carries an access tier:
type AccessTier = 'paid' | 'free_first' | 'free_slow';
The first successful guest generation can enter immediately. Later free requests can use a slower queue. Paid requests follow the credit path.
This is cleaner than sprinkling checks such as if (isGuest) throughout the workflow. The workflow receives a concrete policy and acts on it. It also means signing in does not silently reset a person's completed guest history.
For the free path, the server also fixes the expensive parameters to a known model, resolution, quality, provider, and one output image. Letting the client choose arbitrary settings would make the cost cap meaningless.
The queue is part of the product
I did not want the second free request to hit a dead end. A slower queue is a more honest trade-off: the request exists, has a real execution time, and will eventually run.
The delay is calculated when the job is created and stored with that job. If an administrator changes the queue configuration later, existing jobs keep their original schedule. That prevents the UI from showing a moving target.
The workflow can wait for either of two things:
- the free delay to expire;
- a verified upgrade event that allows the same logical job to continue sooner.
The second path must not create a replacement provider request. Payment callbacks, page refreshes, and repeated clicks all need to converge on the same job.
Put the expensive work in a durable workflow
An HTTP request is a poor owner for an image-generation job. The connection may disappear long before the provider finishes.
I use a Cloudflare Workflow for the long-running path. The request handler validates the input, creates a pending history record, and enqueues the workflow. From that point, the workflow owns the job:
validate request
-> reserve guest entitlement and budget
-> create generation history
-> enqueue workflow
-> submit to image provider
-> poll for completion
-> store result
-> finalize history
Provider submission, polling, and storage are retryable steps. The browser only polls the history ID, so reconnecting does not restart the generation.
Paid generations consume credits inside the workflow. If a later step fails, the compensation step refunds the same usage transaction and marks the job failed. Guest jobs do not need a credit refund, but their claim still needs an accurate final state.
Keeping debit and refund under the same workflow owner removed a class of bugs where the API returned an error but a background request continued spending money.
Anonymous should not mean unowned
Guest files still need ownership and privacy.
Reference images are uploaded to private storage and attached to a server-side anonymous identity. The server checks ownership before using a file in a generation request. Generated outputs also belong to that identity rather than living at an untracked public URL.
If the visitor later signs in, the system links the guest claim, history, and files to the verified account. The same completed guest generation still counts after the identity changes.
Anonymous content is temporary. In my current design it is eligible for cleanup after 72 hours, so the product tells visitors to download anything important. “No sign-up” should not be presented as “permanent storage without an account.”
Fail before spending whenever possible
The cheapest provider call is the one that never needs to happen.
File type, file size, ownership, model capabilities, prompt policy, anti-bot verification, concurrency, and budget checks all happen before provider submission. A rejected prompt should not consume the guest allowance. A malformed upload should not create a generation job.
There is still no perfect abuse-prevention rule. IP limits can affect shared networks, identity limits can be evaded, and CAPTCHA adds friction. I found it more useful to layer several imperfect signals and keep a global kill switch than to pretend one identifier is reliable.
What I would keep if I rebuilt it
The code will change, but I would keep these ideas:
- Model free usage as a reservation, not a boolean.
- Give each generation a durable server-side identity.
- Make duplicate requests converge on the same job.
- Reserve estimated cost before provider submission.
- Put failure compensation beside the work it compensates.
- Keep anonymous files private and temporary.
- Show only states and waiting times the server can prove.
The surprising part was that the image model was not the hardest component. The hard part was defining who owns a request while the user, the browser, the worker, and the provider can all disappear independently.
Once the free generation became a proper job instead of a marketing exception, the rest of the system became much easier to explain and test.
Top comments (0)