Image upload flows are easy to underestimate in SaaS apps. The first version might be a file input, a spinner, and a processed image response. That works until users refresh the page, upload HEIC files, hit size limits, retry failed jobs, or expect the original image to still be available later. In this article, I’ll use BackgroundErase Studio as a concrete example, but the pattern applies to most SaaS image-processing features: save the right source, decide what version to process, keep API keys server-side, move durable work into jobs, and make failures recoverable.
The challenging portions are the contracts around the upload: what you keep, what you transform, when you send the original image, when you create a smaller working copy, how you recover from processor failures, and what the user sees while the work is happening.
I'll use BackgroundErase Studio as the concrete
example because it is a small version of the same system. Studio
validates a user image in the browser, converts HEIC when needed, sends
a multipart image_file request to an internal endpoint,
and displays the returned transparent PNG. A full SaaS app usually
adds durable storage and a queue, but the core tradeoffs are the same.
Core idea
Keep the original safe. Process the right derivative.
- Accept one clear upload contract instead of every possible input shape.
- Store the original or a durable source before you start expensive work.
- Only compress or resize when it preserves the job the user cares about.
- Move long-running processing into jobs once the request can outlive user patience.
- Make failure states visible enough that users know what to retry.
01
Start with the upload contract
The first useful design decision is deciding what the upload means. Is the uploaded file the canonical source of truth? Is it just a temporary input to a processing step? Can the user re-download it later? Can they apply a different background, crop, or format without uploading again?
For image processing in a SaaS app, I like to separate the upload into three artifacts: the original source, a working input, and one or more outputs. Sometimes the original and working input are the same object. Sometimes they are not. Keeping the distinction clear prevents a lot of later confusion.
Source
The file or URL the user gave you. This is what you want to keep if the user may need a retry, a different output format, audit history, or a future edit.
Working input
The bytes you send to the processor. It might be the source image, a HEIC-to-JPEG conversion, or a resized copy that fits the processor limits.
Result
The artifact your product uses: a transparent PNG, a flattened JPEG on white, an alpha mask, or a set of channel-specific outputs.
Record
The database state that connects the user, file, options, attempts, output keys, and current status. This is what lets the UI survive refreshes and retries.
This contract also answers a common API question: should you send a
multipart file, an image URL, or base64? For direct user uploads,
multipart image_file is the best default. It avoids
base64 expansion, works naturally from browsers and workers, and
lets the processor receive the original file bytes. Use image_url when the source already lives in object
storage or a third-party media system. Avoid base64 unless your
platform forces it, because it makes payloads larger and tends to
push more data through memory.
02
The Studio version
Studio is intentionally direct because it is an interactive tool. A user chooses one image, waits for that one image, and sees the result immediately. That makes a synchronous request acceptable as long as the UI can show progress, surface errors, and abandon stale results when the user uploads a different file.
The flow is still doing more than "send whatever the browser gave
us." Studio checks that the file is not empty, enforces the public
upload limit, accepts only known image MIME types, reads the first
bytes to reject obviously invalid files, converts HEIC or HEIF to a
high-quality JPEG in the browser, and opens the image with createImageBitmap to reject dimensions that are too
small or wildly large.
Studio client request
const formData = new FormData();
formData.append("image_file", fileForProcessing, fileForProcessing.name || "upload");
formData.append("format", "png");
formData.append("channels", "rgba");
formData.append("size", "full");
const response = await fetch("/api/process-image", {
method: "POST",
headers: await authHeaders(),
body: formData,
});
if (!response.ok) {
throw new Error(await readProcessImageError(response));
}
const blob = await response.blob();
processedObjectUrl = URL.createObjectURL(blob);The internal /api/process-image endpoint is a proxy, not
the image processor itself. That matters. The browser should not hold
the private processor API key, and the app needs a place to enforce
auth, plan state, daily usage limits, request shape, and upstream
error handling.
Proxy shape
export async function POST({ request }) {
const contentLength = Number(request.headers.get("content-length") || 0);
if (contentLength > MAX_PROXY_BODY_BYTES) {
return json({ error: "Payload too large" }, { status: 413 });
}
const contentType = request.headers.get("content-type") || "";
if (!contentType.toLowerCase().includes("multipart/form-data")) {
return json({ error: "Expected multipart form data with image_file" }, { status: 415 });
}
const upstream = await fetch(PROCESSOR_URL, {
method: "POST",
headers: copyHeadersForUpstream(request),
body: request.body,
duplex: "half",
});
if (!upstream.ok) {
throw new UpstreamResponseError(upstream.status, await readUpstreamError(upstream));
}
const upstreamContentType = upstream.headers.get("content-type") || "image/png";
if (!upstreamContentType.toLowerCase().startsWith("image/")) {
throw new Error("Processor did not return an image");
}
return new Response(upstream.body, {
headers: {
"Content-Type": upstreamContentType,
"Cache-Control": "no-store",
},
});
}Why this is okay for Studio
The whole product moment is "upload one image and wait." If the request fails, the user is still on the page with the source preview visible and can retry immediately.
Why this is not always enough
If the upload belongs to a listing, campaign, profile, catalog, or onboarding flow, losing the result on refresh is not acceptable. Store state before processing.
03
Original, compressed, or resized?
The short answer: send the original when it fits the processor limits and the output quality matters. Compress or resize only when the original is too large, too slow, in a browser-unfriendly format, or much higher resolution than the product can use.
Background removal is especially sensitive to this choice because the interesting information is often on the edge: hair, glass, product shadows, thin straps, spokes, labels, texture, and low contrast boundaries. Heavy JPEG compression can create blocks around those edges. Aggressive downscaling can turn a detailed boundary into mush. You may save time on the upload and then pay for it in output quality.
Send the original
Best when the image is under the upload limit, the user expects full-resolution output, or the subject has fine edge detail. This is the default I would choose for Studio-style processing.
Re-encode without resizing
Useful for HEIC/HEIF, broken metadata, odd camera exports, or huge files whose pixels are reasonable but whose container is not. Use high quality and do it once.
Resize before processing
Reasonable when the image exceeds the processor limits, the app only displays a bounded size, or latency matters more than preserving every pixel.
Do not base64 by default
Base64 makes the payload roughly a third larger and usually forces the whole body through strings. It is convenient for some JSON-only systems, but not a good browser upload default.
A practical default: keep the original, process the original when it is below the API limits, and create exactly one high-quality working derivative when it is not. Record which one was sent so a user report can be traced later.
For BackgroundErase specifically, the normal API limit is 30 MB and decoded images may not exceed 100,000,000 pixels. That means a 12 MB product JPEG should usually go through untouched. A 48 MB phone photo might need a high-quality re-encode or a long-edge cap before processing. A 12,000 by 12,000 image needs dimension handling even if the file happens to compress under the byte limit.
Also be careful with transparent PNGs, screenshots, logos, and text overlays. Converting those to JPEG can destroy the exact edges that made the file useful. If your app accepts mixed image types, do not apply one compression rule to all of them. Branch by format, pixel dimensions, file size, and expected output.
04
Keep work out of fragile requests
A synchronous request is simple, and sometimes that simplicity is the right tradeoff. Studio can do it because the user is knowingly waiting for one image. Most SaaS product flows should use a job boundary sooner than people expect.
The upload request should be responsible for accepting the file, validating the obvious things, storing the source, and creating a record. The processing worker should be responsible for calling the image API, writing outputs, and moving the record through state. That separation gives you retries, better logs, and a UI that survives a refresh.
Async lifecycle
Browser upload
-> create upload record
-> store original object
-> enqueue processing job
-> worker creates result object
-> update record to ready
-> browser polls, subscribes, or receives callbackThe source exists and the record has been created.
A worker has not picked up the job yet. The UI can show a stable pending state.
The job has started. Store attempt count, worker ID, and timestamps.
The output exists and the app can render, attach, or publish it.
The app knows the difference between a user-fixable input problem and a retryable system problem.
The biggest mistake is tying the entire feature to the browser staying connected. Mobile browsers suspend tabs. Users double-click. Corporate networks kill long requests. Serverless functions have timeouts. A queue helps prevent this by normalizing obscure edge cases across the board.
05
Storage and idempotency
Once an upload can outlive the request, storage layout matters. I would avoid scattering original files and results under unrelated keys. Put everything for one upload or job under a predictable prefix so support, cleanup, and reprocessing are boring.
Object layout
uploads/upl_01hzk8/raw/original.jpg
uploads/upl_01hzk8/working/input.jpg
uploads/upl_01hzk8/results/background-removed.png
uploads/upl_01hzk8/results/mask.png
uploads/upl_01hzk8/metadata.jsonThe database record is just as important as the object keys. It should know which source was uploaded, which working derivative was processed, which options were used, where the result landed, and whether the job can be safely retried.
Upload record
{
"id": "upl_01hzk8",
"userId": "user_123",
"status": "processing",
"input": {
"originalKey": "uploads/upl_01hzk8/raw/original.jpg",
"workingKey": "uploads/upl_01hzk8/working/input.jpg",
"sha256": "d0f1f7..."
},
"output": {
"format": "png",
"channels": "rgba",
"resultKey": null
},
"attempts": 1,
"createdAt": "2026-07-05T15:40:00.000Z",
"updatedAt": "2026-07-05T15:40:03.000Z"
}Idempotency is what keeps retries from creating nonsense. If the user retries the same upload with the same options, you should be able to either return the existing result or safely overwrite the same output key. Use a stable upload ID, an input hash, and a normalized options hash. Do not use "current timestamp plus random filename" as your only source of truth unless you enjoy archaeology.
Keep originals longer than working files
Working derivatives can often be regenerated. Originals cannot, unless the user uploads again. Apply retention with that asymmetry in mind.
Version the processor options
Store format, channels, bg_color, size, model version when
available, and any preprocessing rule that changed the input.
06
Validation and failure states
Upload validation should happen in layers. Client-side checks make the product feel immediate, but server-side checks are the ones that protect the system. The browser can tell a user "that file is too large" before the upload starts. The server still needs to enforce byte limits, content type, auth, quota, and processor options.
413 payload too large
Tell the user whether to compress, resize, or upload a different file. In batch systems, mark the item failed permanently unless your worker can create an allowed derivative.
415 unsupported media type
This is usually a request-shape problem. For BackgroundErase,
multipart form data with one image_file is the
easiest production path.
422 invalid image
The file extension and MIME type are not enough. Read magic bytes, try to decode dimensions, and show a message that asks for a real JPEG, PNG, WebP, HEIC, or HEIF image.
429 rate limited
Back off and retry in workers. In the UI, avoid pretending the upload failed because of the user. This is a capacity or plan state, not a bad file.
Timeouts and 5xx responses
Treat these as retryable with attempt limits. If the original is stored, the user does not need to upload again just because a worker or upstream provider had a rough minute.
Processor returned non-image data
Check status and Content-Type before you create a
preview URL. Save the response body preview to logs, not to the
user-facing image slot.
A good user-facing flow separates "you can fix this" from "we are still working on it." Too large, unsupported, corrupt, and too small are user-fixable. Rate limits, upstream timeouts, and transient processor errors are system states. That distinction changes the button copy, the retry behavior, and the support burden.
07
Production checklist
The shape I would start with depends on the product moment. If you are building a fast interactive editor, the Studio pattern is fine: validate hard, post multipart, stream the response, and keep the UI honest about loading and errors. If the image belongs to a durable business object, introduce storage and jobs before users depend on the result.
Upload
Enforce byte limits, accepted MIME types, magic-byte checks, decoded dimensions, and one primary input source.
Preprocess
Convert HEIC when needed, preserve originals, resize only for a clear reason, and record the derivative that was processed.
Process
Send multipart image_file by default, keep API keys
on the server, check response status, and require an image Content-Type.
Persist
Store outputs under deterministic keys, update a job record, and make retries idempotent.
Observe
Log upload ID, request ID, processor status, retry count, input hash, and final object key. You will want them later.
Explain
Give users clear messages for bad files, plan limits, queued work, retries, and completed output.
Build the upload flow around recovery.
The happy path is easy: receive image, process image, show result. The production path is about preserving enough state that a failed request, refreshed tab, duplicate click, large source file, or upstream retry does not make the user start over.
Read the API docs