What it used to be
The original build was a Django API on Render with a Pillow and OpenCV processor per tool, plus Cloudinary for storage. You picked a file, the browser POSTed it to an endpoint like /api/tools/compress/, a Python function opened it, did the work, uploaded the output, and returned a JSON body containing a result_url pointing at Cloudinary.
It worked. It was also the wrong shape for what these tools actually do. Compressing a JPEG is a decode, a re-encode, and nothing else. There is no database row to write, no state to keep, no computation a phone cannot do. The entire server existed to move bytes to a machine that would do the same arithmetic and move them back.
The costs were real and they were all avoidable. On Render's free tier the service slept, so the first request after an idle period paid a cold start before any image work began. Every upload spent the user's bandwidth twice, once up and once back down. Cloudinary meant a storage bill that scaled with traffic and a privacy claim I had to ask people to take on faith.
The claim that bothered me
The site used to say files were deleted after an hour. That was true, and it was still a bad answer. It asks the visitor to trust a promise about a server they cannot inspect, made by a person they have never met, about a photo they have already handed over.
For most of these tools the honest version is better: your file does not need to go anywhere. Someone scanning a passport photo or a document has a real reason to care where it went, and the strongest possible answer is that there is nowhere for it to go.
That is the actual motivation for the rewrite. Speed and hosting cost were nice. Being able to make a claim the visitor can verify themselves, in fifteen seconds, with their own developer tools, was the point.
What replaced it
Three browser APIs cover almost everything the Python did.
Canvas handles decode, draw and encode. createImageBitmap decodes any format the browser knows, drawImage does resizing and cropping, and the canvas encoders write JPEG, PNG or WebP at a quality you choose. That is the compressor, the resizer and the format converter in their entirety, because the browser already ships the same encoders the server was calling.
Web Workers handle anything slow. The domain-transform filters behind sketch, cartoon and enhance are linear in pixel count but with a large constant, and at 2000px they block the main thread long enough to freeze the tab. They run in a worker, with pixel buffers transferred rather than copied so there is no serialisation cost on a multi-megabyte frame.
WebAssembly covers the one case with no browser equivalent. QR decoding uses zxing-wasm, the same ZXing library the Python was calling through OpenCV. The binary is about a megabyte and is served from our own origin rather than a CDN, so the tool keeps working without a third-party request. Both the library and the binary load on first use, never on page load.
The migration was incremental, not a rewrite
What made this manageable was leaving the call signature alone. Tool components still call postForm with a path and a FormData, and still read result_url off the response. What changed is a lookup table in the API client that maps each endpoint path to a local runner.
A path present in the table runs locally. A path absent from it still goes over the network. That meant one tool could move at a time, in its own commit, with the rest of the site untouched, and if a runner turned out to be wrong, reverting it was a one-line change rather than a rollback.
The runners are imported dynamically, so a page only downloads the code for the tool it actually runs. Opening the compressor does not pull in the panorama stitcher or the megabyte of QR wasm.
- /api/tools/compress/ maps to runCompress
- /api/tools/resize/ maps to runResize
- /api/tools/qr-scan/ maps to runQR
- and so on, one entry per tool
The parts that were not free
Anyone telling you a migration like this is painless has not done one. Three things bit.
Alpha handling changed silently. Pillow flattened RGBA and palette images to RGB before writing a JPEG. The Canvas JPEG encoder also composites, but onto black instead of white. Every PNG with transparency came out with a black background. The fix is three lines, filling white underneath with destination-over compositing before encoding, but the bug is invisible until someone uploads a logo.
Memory management came back. A Cloudinary result_url was an https link that needed no cleanup. A blob: URL is owned by the tab and holds its decoded bytes until it is revoked. Without that, every run of a tool leaked a full-resolution image until reload. Results are now keyed by tool, and a new result revokes the previous URL for that channel.
And some tools did not survive. Background removal, face detection, object detection and age estimation were doing real model inference server-side. Those four are switched off rather than shipped broken. Their pages still resolve so no URL 404s, but they are marked noindex and say plainly that the tool is unavailable. Shipping a worse version of a tool that used to work is not a migration, it is a downgrade with better hosting.
What it actually bought
No cold starts, because there is no server to wake up. The work begins on the same tick the file is dropped.
No upload, so no waiting on the visitor's upstream bandwidth, which on mobile is usually the slowest link in the chain. A 4 MB photo that took several seconds to send now takes none.
No storage bill, no per-request cost, and a site that scales to any amount of traffic because it is static files behind a CDN.
And the claim is now verifiable rather than asserted. Open developer tools, watch the network tab, compress an image: no request is made. That is a much better answer than a retention policy, and it is the one thing about this rewrite I would not trade back.
When not to do this
This works because image editing is embarrassingly parallel, stateless, and small enough to fit in a tab. Change any of those and the calculus flips.
Keep the server if the work needs a model too large to ship, if the result must be shared between users or devices, if the output has to exist after the tab closes, or if you are processing batches large enough that a phone would thermally throttle. The four disabled tools are exactly the first case.
The rule of thumb: if the server is only doing arithmetic on bytes the user already has, it is a middleman. If it holds state, coordinates between people, or knows something the client cannot, it is doing a job. Ours was a middleman for ten of fourteen tools, so it went.
Frequently asked questions
Is client-side processing slower than a server?
For these operations, no. The arithmetic is the same and a modern phone is fast at it, so you save the round trip entirely. A 4 MB photo that spent several seconds uploading now starts processing immediately. The exception is heavy model inference, which is why four tools stayed off rather than moving.
What happens on an old phone?
Canvas and Web Workers are supported everywhere that matters, so the tools run. Very large images on low-memory devices are the real limit, since decoding a 50-megapixel file needs the full bitmap in memory. The slow filters run off the main thread specifically so a weak device stays responsive rather than freezing.
How can I verify nothing is uploaded?
Open your browser's developer tools, switch to the Network tab, and run any tool. No request is made with your file. You can also disconnect from the internet after the page has loaded and the tools still work, because everything needed is already in the tab.
Why does the QR scanner use WebAssembly instead of a JavaScript library?
The original backend used ZXing through OpenCV, including multi-symbol decode with corner points. jsQR handles only a single code, so switching to it would have quietly dropped existing behaviour. zxing-wasm is the same library compiled to WebAssembly, so the behaviour carries over exactly.
Does this mean the tools work offline?
After the page and its runner have loaded, yes, because the processing itself needs no network. There is no service worker yet, so a cold visit still needs a connection to fetch the page.
Why were four tools switched off instead of moved?
Background removal, face detection, object detection and age estimation were doing real model inference on the server. Porting them would have meant shipping large models to the browser or accepting much worse results. Their pages stay reachable and noindexed, and say plainly that the tool is unavailable.