DEV Community

Cover image for I Added Image Upload to an AI Product. The Hard Part Was Everything Around It.
Warren
Warren

Posted on

I Added Image Upload to an AI Product. The Hard Part Was Everything Around It.

I recently added an Image to Tattoo workflow to AIMakeTattoo.

From the user’s perspective, the feature looks straightforward:

  1. Upload a photo or sketch.
  2. Choose a tattoo style.
  3. Adjust proportion and complexity.
  4. Generate a tattoo design.

From the application’s perspective, image upload introduced a much larger set of concerns:

  • multipart request parsing
  • file validation
  • image moderation
  • anonymous and signed-in quotas
  • paid credits
  • concurrent-job protection
  • provider uploads
  • OAuth state loss
  • privacy decisions
  • cleanup after partial failure

The image generation request itself was one of the easier parts.

Image upload changed the request boundary

The existing text-to-image flow accepted structured fields.

The server could validate the prompt and selected controls, check whether the user was eligible to generate, submit a job, and return a job ID.

Adding a file changed that boundary.

The route now had to answer several new questions:

  • Which file types should be accepted?
  • How large can an individual image be?
  • Should the complete multipart request have its own size limit?
  • When should moderation run?
  • Should an ineligible request reach moderation at all?
  • Should the application store the original image?
  • What happens when the user signs in after choosing a file?
  • Which failures require releasing a generation lock?
  • At what point should a paid credit be consumed?

None of these questions directly improved the generated image.

They determined whether the feature behaved like a reliable part of the product.

The first request flow worked, but it did unnecessary work

My initial flow was roughly:

  1. Parse the multipart request.
  2. Resolve the user or anonymous visitor.
  3. Acquire an active-generation lock.
  4. Moderate the uploaded image.
  5. Check free quota and paid credits.
  6. Upload the image to the generation provider.
  7. Submit the generation job.
  8. Store the job metadata.
  9. Consume the correct allowance.

The happy path worked.

But a user who had clearly exhausted every available allowance could still reach image moderation before the request was rejected.

That meant the application was doing work for a generation that could never proceed.

I added an early eligibility check

I moved a read-only eligibility check closer to the beginning of the request.

The revised flow became:

  1. Parse and validate the request.
  2. Resolve the user or anonymous visitor.
  3. Read the current allowance and credit state.
  4. Reject requests that are obviously ineligible.
  5. Acquire the active-generation lock.
  6. Moderate the image.
  7. Run the final authoritative eligibility checks.
  8. Upload the image and submit the generation job.
  9. Persist the job metadata.
  10. Consume the correct allowance or credit.

The early check can reject obvious cases, such as:

  • an anonymous visitor who has exhausted the daily allowance
  • a signed-in user who has reached the relevant limit
  • a user with no free generations and no paid credits

But this early check is deliberately advisory.

It does not consume anything, and it does not authorize the final generation.

It only answers:

Is this request clearly unable to continue?

Why the early check cannot be authoritative

A quota read can become stale immediately.

Suppose a user has one free generation remaining and sends two requests at nearly the same time.

Both requests may see the same initial state.

A paid-credit balance can also change between the early check and the point where the provider job is submitted.

The final check therefore answers a different question:

Is this request allowed to consume a generation right now?

The two checks may look repetitive, but they have different responsibilities.

The early check avoids unnecessary work.

The final check protects the state change.

This also means the advisory check should fail open when it cannot make a confident decision. A temporary problem in an optimization step should not become a new reason to reject legitimate users.

The lock created the most dangerous failure mode

AIMakeTattoo allows only one active generation per user.

That reduces accidental double submissions and prevents concurrent requests from trying to use the same allowance.

The route acquires an active-job lock before the expensive part of the workflow.

The obvious rejection paths released that lock correctly.

The more dangerous issue was an unexpected exception after the lock had already been acquired.

For example:

  1. The user starts a generation.
  2. The application acquires the lock.
  3. The moderation service throws unexpectedly.
  4. The request fails.
  5. The lock remains.
  6. The next attempt is rejected because the application still thinks a job is active.

The original failure may have been temporary.

The leaked lock turns it into a persistent product problem.

Every post-lock operation needs a cleanup path

The lesson was broader than image moderation:

Once a lock has been acquired, every later operation must live inside a cleanup boundary.

That includes failures during:

  • moderation
  • final quota validation
  • paid-balance validation
  • provider upload
  • provider job submission
  • local job persistence
  • allowance or credit consumption

The simplified structure looks like this:


ts
const lock = await acquireActiveJobLock(userKey);

try {
  await moderateImage(file);
  await runAuthoritativeEligibilityChecks(user);
  await uploadAndSubmitGeneration(file, controls);
  await persistJobMetadata();
} catch (error) {
  await releaseActiveJobLock(lock);
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)