Every photo tool starts the same way: upload the file, process it on a server, send it back. It is the obvious design, every tutorial does it that way, and for a lot of products it is the wrong one.
I built a passport photo tool where the image never leaves the browser. No upload endpoint exists. That is not a privacy marketing line — it removes an entire category of work, cost and risk from the system.
01The bandwidth argument
A phone photo is 3–6 MB. If a user resizes it, you pay to receive it, pay to store it while you work, pay to send the result back, then pay to delete it. For a 400 KB output you moved 6 MB across the network twice over.
Do the arithmetic on any traffic estimate and it stops making sense. Ten thousand sessions a month at 6 MB in and 400 KB out is roughly 64 GB of transfer to produce maybe 4 GB of finished files. The server does almost nothing but shuffle bytes.
Client-side, that cost is zero. The bytes are already on the device; the CPU is already idle.
02Privacy is a feature you can prove
"We do not store your photos" is a promise. Anyone can write it in a policy and nobody can check it.
"There is no upload endpoint" is a fact. A user can open dev tools, watch the network tab, and see nothing leave. That difference matters more than it sounds: for passport photos you are handling a document image that people are already nervous about.
03What actually breaks
It is not free. The failure modes move from your server to their device:
- Memory. A 12 MP image decoded is about 48 MB of RGBA before you do anything. On a low-end Android device, four of those at once will kill the tab. Downscale before you store a copy, and never keep the original bitmap alive after you draw it.
- iOS canvas limits. Safari historically caps canvas area around 16.7 million pixels. A 6000×4000 image will fail silently in some paths. Resampling the source down to something sane before painting avoids the whole class of problem.
- Encoding is async.
toBlobis a callback, not a return value. Any quality search has to be written as a recursive callback, not a loop. - Old browsers. Not a concern for most audiences, but decide deliberately rather than discovering it.
04The core of it
One function does the actual work. Everything else is UI.
function draw(ctx, W, H, img, zoom, ox, oy) {
// cover: fill W×H, never letterbox
var s = Math.max(W / img.naturalWidth, H / img.naturalHeight) * zoom;
var dw = img.naturalWidth * s, dh = img.naturalHeight * s;
// pan range, normalised so preview and export share one state
var maxX = Math.max(0, (dw - W) / 2);
var maxY = Math.max(0, (dh - H) / 2);
ctx.drawImage(
img,
(W - dw) / 2 + ox * maxX,
(H - dh) / 2 + oy * maxY,
dw, dh
);
}
The important detail is that ox and oy are normalised to [-1, 1] rather than stored as pixels. That way the same state renders correctly on a 300 px preview canvas and a 413 × 531 export canvas. Store pixels and you get two sources of truth that drift apart the moment the window resizes.
05When you should not do this
Client-side is not a rule, it is a trade. Do it on the server when:
- The logic is secret or expensive to copy — you are giving the algorithm to anyone who opens the bundle.
- The output must be reproducible for audit — a server can log inputs and parameters, a browser cannot prove anything.
- You need heavy compute — video transcoding, large model inference. WASM helps but there are limits.
- You need to gate it behind payment. Anything that runs locally runs after someone deletes your gate.
For a crop-and-compress tool, none of those apply. So it runs in the browser, and the server only exists to serve the HTML.
Worth keeping
- Move work to the client when the operation is simple, deterministic and sensitive.
- Normalise interaction state, not pixels, if preview and export need to agree.
- "No upload endpoint" is a stronger claim than "we delete your files" — because it is checkable.