DEV Community

Lim Geng
Lim Geng

Posted on Edited on AI-assisted

How I Handle Async Image Generation, Webhooks, and Credits in My AI Photo Editor

AI Photo Editor Screenshot

Lessons from building an AI photo editing tool with TanStack Start, async jobs, and a credit system.

I recently built a small AI photo editor called GetAIPhotoEditor. The idea was simple: upload a photo, describe what you want to change, and get an edited image back.

No Photoshop, no layers, no complicated tools.

The first version wasn't particularly difficult to put together. I already had a SaaS starter with authentication, payments, and a credit system. I added an image upload interface, connected an image editing API, and built the result preview.

Then I ran into a problem.

An image generation request could succeed on the AI provider's side but still appear as failed in my application.

That turned out to be much more interesting than the image editing itself.

A simple UI, a less simple backend

I built the app with TanStack Start, React 19, TypeScript, and Drizzle ORM.

For image editing, I use GPT Image 2.6 through fal.ai or Replicate. Images are stored in Cloudflare R2, and users pay for generations using credits.

The interface is intentionally simple. There's an upload field, a prompt input, a quality selector, and an area to display the result.

Behind that interface, a generation request goes through several steps:

  1. Validate the image and prompt.
  2. Check the user's credits and create a task record.
  3. Submit the job to the AI provider.
  4. Wait for the provider to finish.
  5. Save the resulting image to my own storage.
  6. Update the task status and show the result.

The provider calls are asynchronous. I don't keep the original HTTP request open until the image is ready.

Instead, the server creates a task and returns its ID. The frontend polls for updates.

It worked fine during normal testing.

Until I considered what happens when the user closes the browser.

The browser shouldn't be responsible for saving the result

Poll VS WebHook

My original implementation relied on frontend polling to retrieve and persist the generated image.

Here's the problem.

A user submits an edit, gets tired of waiting, and closes the tab. The AI provider continues processing and eventually finishes the job.

But because the browser isn't polling anymore, my application might never fetch the finished image.

The generated image is still sitting on the provider's infrastructure. And those temporary results aren't necessarily available forever.

I encountered cases where the provider had completed the job, but by the time my application tried to reconcile it, the output was no longer recoverable.

The task ended up marked as failed, even though the AI generation had actually succeeded.

I had already added a reconciliation mechanism that checks unfinished tasks. It also marks stale tasks as failed after a timeout, rather than leaving them pending indefinitely.

That prevents stuck tasks, but it doesn't fully solve the problem of retrieving results before they disappear.

So I'm changing the design to use webhooks.

With the new flow, the AI provider sends a callback when processing finishes. The server verifies the callback, identifies the task, saves the image to R2, and updates the database.

The browser can still poll to update the UI, but the actual result handling no longer depends on whether the user keeps the page open.

I'm keeping polling and periodic reconciliation as fallback mechanisms.

There's another detail here: I don't want to trust an incoming webhook just because it contains a valid-looking task ID.

The callback needs signature verification, and its provider task ID must match the job my application created.

I also need to handle a race where the webhook arrives before the original submission request has finished saving the provider's task ID.

This is the kind of edge case that doesn't show up in a quick demo.

The main lesson for me was that a successful provider response isn't the same thing as a successfully delivered result.

The output isn't really available to the user until I've saved it somewhere I control.

Credits introduced another set of problems

AI image generation costs money, so I use a credit-based system.

Different quality levels consume different amounts of credits. The server calculates the cost, rather than accepting a price supplied by the browser.

That part is straightforward.

The difficult questions are about what happens when something goes wrong.

What if a user clicks Generate twice?

What if the request times out after the AI provider has already accepted the job?

What if two processes try to mark the same task as completed?

And what if a failed task needs its credits returned?

For duplicate submissions, I generate a deterministic task ID from the user ID, request ID, and selected quality.

The relevant part of my code looks like this:

const stableId =
  'edit_' + md5(
    user.id + ':' + requestId + ':' + charge.quality
  );
Enter fullscreen mode Exit fullscreen mode

If the same request comes in again, the server can find the existing task instead of blindly creating another one.

But an ID alone isn't enough.

Creating a task and deducting credits must be treated as one operation. Otherwise, a database failure could leave a task without a charge, or a charge without a task.

I use an atomic database operation to write both together.

Task status updates also need guards. Once a task reaches a terminal state, a delayed response shouldn't overwrite it.

For example, if one request has already saved a successful result, another process shouldn't be able to mark that same job as failed and refund its credits.

The failure path needs just as much attention as the success path.

One particularly awkward case is a timeout during submission.

A timeout doesn't always mean the provider rejected the request. It might have accepted the job and started processing, while my application never received the response.

Automatically submitting the same request again could mean paying for two generations.

That's why I don't blindly retry a job against another provider when the outcome of the first submission is unknown.

It's tempting to treat every network failure as something you can fix with a retry. With paid APIs, retries can have a real cost.

Not every image task should use a generative model

Another decision was separating general photo editing from background removal.

For general editing, a generative model makes sense. Users can describe changes like replacing a background, removing an object, or adjusting lighting.

But there's a difference between asking a model to create a clean-looking background and asking it to remove the background completely.

For a transparent PNG cutout, I use a dedicated background removal model instead of relying on a generative image editor.

The distinction matters because users expect different things from these features.

If someone asks to replace a background with a beach, generating a new scene is fine.

If someone asks to remove the background from a product photo, they usually expect the original product to remain intact, with a real transparent background.

Generative editing can unexpectedly modify small details. That might include a product logo, facial features, or the shape of an object.

A more detailed prompt can help, but it isn't a guarantee.

I'm learning to treat these as different operations, even though they might look similar in the UI.

What I'd do differently

If I were starting again, I'd think about task recovery and result storage earlier.

It's easy to focus on the happy path: the user uploads an image, the API returns a result, and everything looks great.

But the less interesting cases matter just as much.

Users close tabs. Networks time out. Providers return unexpected responses. Callbacks arrive late or more than once.

I would also separate the responsibilities of the browser and the backend more clearly from the beginning.

The frontend should handle the editing experience and display progress. It shouldn't be required to keep running for a paid generation to finish properly.

And I'd still keep the initial product small.

I don't think this application needs a complicated workflow engine or a large microservice architecture. A task table, carefully controlled state transitions, reliable storage, and a few recovery mechanisms can go a long way.

The important thing is knowing which failures the system needs to survive.

Final thoughts

Building the first version of an AI photo editor was mostly about connecting existing pieces.

Making those pieces work reliably together has been the more interesting part.

I'm still improving the task processing flow, particularly around webhooks and recovery. There's more to test before I'd call it fully reliable.

But one thing is already clear to me:

A demo only needs to work while you're watching it. A real product needs to keep working after the user closes the tab.

If you've built something around asynchronous AI APIs, I'd be interested to hear how you handle duplicate requests, lost callbacks, and credit refunds.

Top comments (0)