DEV Community

hunter su
hunter su

Posted on Fully Autonomous

Four guardrails I learned while building an AI photo restoration workflow

Disclosure: I am affiliated with RestorePhoto, the product discussed in this post.

A photo restoration demo is easy to understand: upload an old portrait, run a model, and show a sharper face.

The product around that demo is harder. A face restoration model does not recover a hidden original. It predicts details that look plausible. That distinction affects the interface, error handling, billing, and even the words used on the result screen.

While working on a browser-based restoration flow, I ended up treating the model as one uncertain step inside a stricter product state machine.

1. Keep the original as the source of truth

The restored image should never replace the upload silently. Users need both versions because they often recognize identity errors that an image metric will miss: an altered eye shape, a different smile, or skin texture that belongs to nobody in the family.

A useful result view therefore needs:

  • the original and restored image at the same scale;
  • a comparison control that does not hide either version;
  • separate download actions;
  • a short disclosure near the result, not buried in the terms page.

The wording matters. "Recovered detail" implies that the system found information that was present but invisible. "AI-generated restoration" is more accurate. The model may have reconstructed a convincing eyelash or tooth that never existed in the source pixels.

2. Model the workflow as states, not a loading spinner

An image job can fail before upload, during upload, in moderation, in the model provider, or while the result is being stored. A single isLoading boolean quickly turns into contradictory UI.

A small state machine is easier to reason about:

type RestorationState =
  | { type: "idle" }
  | { type: "validating"; file: File }
  | { type: "uploading"; file: File; progress: number }
  | { type: "processing"; jobId: string }
  | { type: "ready"; jobId: string; originalUrl: string; resultUrl: string }
  | { type: "rejected"; reason: "unsupported" | "moderation" }
  | { type: "failed"; retryable: boolean; message: string };
Enter fullscreen mode Exit fullscreen mode

This makes several awkward cases explicit:

  • validation failure should not create a paid job;
  • a moderation rejection should not be presented as a model crash;
  • refreshing the page during processing should resume by job ID;
  • a failed job should not consume a credit unless the charging rule says it did.

The state model also gives analytics cleaner events. upload_failed, moderation_rejected, and provider_failed are more useful than one generic generation_error bucket.

3. Validate twice

Client-side validation improves the experience, but it is not a security boundary. Check the file again on the server before handing it to another service.

At minimum, I would verify:

  • allowed MIME type and decoded image format;
  • file size and pixel dimensions;
  • whether the file can be decoded without exhausting memory;
  • orientation metadata;
  • moderation status before processing;
  • that storage keys and result URLs belong to the current job.

Extensions are not enough. A file named portrait.jpg can contain something else. Re-encoding an accepted upload into a known format can also remove unexpected metadata and reduce the number of formats passed downstream.

The browser should show the limits before someone selects a file. A server error after a large upload is both expensive and irritating.

4. Put the limitation where the decision happens

A general AI disclaimer at the bottom of a website does little work. The useful warning appears when a user is deciding whether to trust or download the result.

For face restoration, I use a direct boundary: the output is a best-effort AI reconstruction and may add plausible details. It should not be used for legal, forensic, immigration, or identity-verification purposes.

That statement is deliberately unexciting. It tells a person what the tool can do and where it stops.

The same principle applies to retention and billing. If an upload is deleted after a particular period, say when the clock starts. If a rejected or failed restoration is not charged, define "failed" in server-side terms and make the credit grant idempotent.

For example, the billing transition should be tied to a durable successful result, not to the user clicking a button:

request accepted
  -> moderation passed
  -> provider completed
  -> result stored
  -> grant/consume entitlement once
  -> return ready result
Enter fullscreen mode Exit fullscreen mode

Retries must reuse the same transaction key. Otherwise, a timeout between the result write and the response can charge twice.

What I would test before launch

My short release checklist now includes cases that a happy-path demo never shows:

  1. A renamed non-image file is rejected.
  2. A huge-dimension image fails before provider processing.
  3. Refreshing during processing resumes safely.
  4. Provider timeout does not produce a phantom success.
  5. Duplicate callbacks do not consume two credits.
  6. The original remains available beside the result.
  7. The limitation text is visible on mobile.
  8. Result URLs cannot be guessed across users.

None of these tests improves the model. They improve the product around the model, which is where most user trust is won or lost.

I am still refining this workflow in RestorePhoto. If you are building an image tool, I would be interested in the failure case that forced you to redesign your own upload or result flow.

Top comments (0)