DEV Community

Cover image for Are You a Web Developer or a Browser Expert? 7 Tests I Failed Building a Photo Editor That Never Uploads Your Photos

Are You a Web Developer or a Browser Expert? 7 Tests I Failed Building a Photo Editor That Never Uploads Your Photos

Akhouri Anmol Kumar on November 01, 2026

Description: On-device AI, WASM threads, target-size compression, inpainting and 3D LUTs in a single HTML file. 7 real lessons from buildi...
Collapse
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

Nice Post!!

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Fastest reading 😆 🤣

Collapse
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

nope...
1st --> comment on post
2nd --> read the post

lol

Thread Thread
 
effessdev profile image
EffessDev •

Okay this thread looks good...

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

yea... very clean

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Lol I can't use gif I'm missing my signature meme

Collapse
 
amorizz profile image
Amorizz •

Test 2 has a storage catch worth one more line. Cache API data is best-effort by default, so under storage pressure the browser can evict the whole origin, 176 MB model included, and the instant second run quietly turns into a fresh download. Calling await navigator.storage.persist() asks for persistent storage, and navigator.storage.estimate() tells you quota and usage before you start the 208 MB object remover. Checking the estimate first beats hitting a QuotaExceededError at cache.put() after every byte already arrived. Chrome grants persist() on its own heuristics, Firefox may ask the user, so treat it as a request.

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Yeah you're actually right I never thought of that it is a request

Collapse
 
amorizz profile image
Amorizz •

It's an easy one to miss because nothing fails when it happens. One more piece: navigator.storage.persisted() tells you whether the grant actually happened, so the app can show it instead of guessing. Safari is worth a line too. Its storage rules can clear script-written data, Cache API included, for sites the user hasn't opened in a while, unless the app is added to the home screen. So for a 176 MB model I'd treat the cache as a speedup and keep the download path tested. Do you show a 'ready offline' state anywhere in the editor?

Collapse
 
sherotree profile image
Sherotree •

The SharedArrayBuffer / COOP-COEP trap is the one I would pin above any ONNX-in-browser checklist. Forcing numThreads = 1 on a static host is boring and correct; the silent phone hang when the "graceful" fallback never fires is the failure mode that actually burns users.

Collapse
 
juto profile image
Pingtou •

The inline Web Worker via Blob URL (Bonus) and the trilinear interpolation math for 3D LUTs (Test 6) are super clean! Doing binary search for target byte-size compression right in the browser is also such a practical trick. Great breakdown of real-world client-side constraints!

Collapse
 
merbayerp profile image
Mustafa ERBAY •

Great work, Akhouri! 🔥 Building all of this inside a single HTML file is impressive, especially the ONNX integration and the way you handle model downloads.

But the part that caught my attention wasn’t the AI. It was the privacy claim.

“Your photos never leave your device” is a powerful promise, but it also creates an interesting security question.

Even without a backend, the browser still executes JavaScript and downloads external resources. If any third-party script gets compromised, the fact that the application has no upload endpoint doesn’t necessarily prevent image data from being transmitted elsewhere.

Have you considered a strict Content Security Policy, dependency integrity verification, or making the entire application work offline after the initial model download?

I’d also be curious whether you’ve tested the application with all network access disabled after loading the models.

To me, that’s the difference between an application designed to process data locally and one whose privacy guarantees can actually be verified.

Really interesting project, bro. Keep building!

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Thanks broooooo 🔥 honestly this is the best question anyone asked on this post as you are the most dangerous bone of the akhouri systems a important bone...

You're right. "No upload endpoint" is only a statement about code, not a guarantee. Any script running in the page can read the canvas. I went and checked my own setup after your comment, so here is the honest list:

  • the only fetch() in my own code is the model download, so nothing of mine sends images anywhere
  • but there is NO CSP yet and NO integrity hashes on the CDN scripts (ORT from cdnjs, Firebase from gstatic, emailjs from jsdelivr)
  • the ad banner runs in a sandboxed iframe, but right now ad.html is same-origin with the app, so that sandbox is weaker than it looks. thats the biggest hole, an ad script is exactly the "compromised third party" case you described "Designed to process locally" vs "can actually be verified" is the line I'm stealing 🙏 thanks broooo the USP is the code doesn't upload your image anywhere, any user can check it using dev tools network... also bro I really loved you read the post but why only reading bruh, I would love if you use APIC WEB, lol you're expert so just bypass signup if you don't want to share it, but please use it...
Collapse
 
merbayerp profile image
Mustafa ERBAY •

Broooo, now THIS is the kind of conversation I love! 😂🔥

First of all, massive respect for actually going back and auditing your own implementation instead of just defending it. That’s exactly how good engineering happens.

And you found some interesting things!

One small distinction about the iframe: a sandboxed same-origin iframe isn’t automatically unsafe. The important details are which sandbox permissions you grant, whether scripts and same-origin access are enabled together, and what the embedded content can actually do.

For the CDN dependencies, I’d prioritize a restrictive CSP and SRI where applicable. But I’d be particularly careful with third-party scripts running in the main document, because once they execute there, they share the page’s privileges.

And here’s another interesting challenge: checking DevTools Network is useful, but it only proves what happened during that particular session. It doesn’t guarantee what a compromised dependency might do tomorrow.

As for APIC Web, haha, you got me! 😂 I spent more time reviewing the architecture than actually using the product.

I’ll give it a proper look. But you know me by now, bro. I’ll probably end up testing the security boundaries before editing a single photo. 😈

Keep building, and keep questioning your own assumptions. That’s where the interesting discoveries happen!

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

that monster emoji by someone with 20-25year+ experienced person bruhh I'm cooked 😂

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

The COOP and COEP lesson is the one I would print out. GitHub Pages cannot send those headers, so you lose SharedArrayBuffer, and the fix that stopped phones hanging was forcing numThreads to 1. That is a hosting decision showing up as a performance bug weeks later.

Switching from a fixed timeout to a stall based one also makes sense when the models range from 5 MB to over 200 MB. A slow phone that is still making progress should not be treated the same as a dead connection.

I liked OnePassApps' suggestion of a DevTools network test as proof for the "never uploads" claim. It turns a privacy promise into something a skeptical reader can check in a minute, and Harry's note about bumping the cache name is the kind of detail that saves a support thread later.

At Ascendra Ventures we try to write claims like that as acceptance tests before a build starts. Did the LaMa to MI-GAN fallback change how you describe quality to users?

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Thanks broo, and a hostiing decision showing up as a performance bug weeks later is exactly how it felt 😅....
Yesss, the fallback changed how I describe quality. The dropdown (Fast / Best / Ultra) is a request, not a promise, so the UI now reports what actually ran. If LaMa fails, the status says "Ultra engine unavailable — using standard AI", and the result label says it was MI-GAN, never "Ultra". If the AI model can't load at all, it says it did a quick fill and suggests retrying. Ultra (~208 MB) is only offered on desktop-class devices, so phones just keep MI-GAN......
i haven't written those as automated acceptance tests yet I like that approach, especially for the "never uploads" claim. A DevToolss network check as a repeatable test is on my list now....

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Got Python badge in this legendary post: dev.to/akhourianmolkumar/are-you-a...
Hope I'll get java script badge in this one... 🔥🔥🔥

Collapse
 
effessdev profile image
EffessDev •

👀

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

😂

Collapse
 
maxbuildsdev profile image
Max Kaya •

Test 3's binary search is the right shape, but it has a quirk worth naming: you accept a mid only when blob.size <= targetBytes, and 7 iterations over [0.05, 0.98] leaves ~0.007 quality slack, so the returned blob can sit a few KB under the cap while a higher q would have fit — on a hard 200 KB limit I'd follow the search with one final probe at the accepted q plus a size guard, since re-encoding the same canvas at the same q can differ by a few bytes across engines and the server checks exact bytes. One question on Test 1: did you keep multi-threading behind the crossOriginIsolated check on hosts that do send COOP/COEP, and if so how much of the ONNX runtime's throughput did the single-thread path give up on desktop — wondering whether pinning numThreads=1 everywhere is costing enough that a proxy worker on a header-friendly host is worth the split code path.

Collapse
 
paullovelace profile image
Paul Lovelace •

Test 1 is the one that got me — "falls back gracefully" on desktop turning into a silent hang on a phone is exactly the kind of bug you only find by testing on the actual device. The stall-based timeout in test 2 is the fix I'd steal though; a fixed 30 seconds would kill every slow connection before the model even arrives. Was the phone hang the first one you hit, or did the PNG-going-black surprise land earlier?

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

I firstly hit actually phone hang png ones because it is more important if we compare to fixing png black issue

Collapse
 
bpecha14 profile image
Bogdan •

Pingtou is spot on about the inline Web Worker via blob URL—that is an absolute lifesaver for keeping the UI thread from locking up during heavy processing.

Building complex AI interfaces (like the dashboard for GrantPro) taught me that the browser is often the real bottleneck, not the backend. I've run into similar memory limit crashes when handling large payloads.

Did you find WASM was consistently faster for the heavy image processing, or did the standard Canvas API hold up well enough for your use case? Great breakdown of the failures!

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Thanks, Bogdan! Glad the blob-URL worker was useful. 🙌

To answer your question: they do different jobs, so it wasn’t really a speed contest. WASM (ONNX Runtime) is how the AI models run: background removal and the object remover. Plain Canvas handles everything else, like resizing, flattening alpha, the JPEG quality search and the colour grading. Canvas holds up fine there because the browser’s own encoders do the heavy lifting.

The catch is that on a static host like GitHub Pages I can’t send the COOP/COEP headers, so WASM runs single-threaded. That means it’s more “reliable on every phone” than “consistently fast”, and I haven’t benchmarked the two against each other, so I won’t pretend I have numbers. 😄

On memory crashes, three things helped me:

inpainting only runs on a crop around the brush instead of the full photo
ONNX’s memory arena and mem-pattern options are switched off
models are cached after the first download

What was the heavy payload in GrantPro? Curious whether you hit the limit in the browser or in the model step.

Collapse
 
rupatoglobal profile image
Rupato •

The transparency example in Test 4 raises a useful batch-export question. From the product-image side at Rupato, I would want the export screen to make two choices explicit: preserve transparency, or flatten onto a chosen background. A preview on white alone can hide the difference.

Does APIC let someone keep PNG transparency for one destination and export a white-background JPEG for another from the same edit? That would be a useful workflow to see alongside the target-file-size check.

Collapse
 
peter7865 profile image
Peter •

Image compression and resizing tricks like this are super handy. When customers upload artwork files for custom boxes on our site at The Printing World, smooth browser resizing saves so many headaches before print production.

Collapse
 
mp3sss31 profile image
mp3sss •

Test 1 hits the exact trap I've seen in Electron: "degrades gracefully on your dev machine" is a claim, not a promise — the equivalent of assuming your renderer behaves identically once COOP/COEP headers are missing or sandbox: true changes what APIs exist. Forcing the one configuration that behaves the same everywhere (ort numThreads=1 here, explicit webPreferences there) is the right call, and checking self.crossOriginIsolated before asking for threads is the same discipline as feature-detecting before touching privileged APIs. The "7 tests" framing is great because every lesson stays falsifiable instead of being a vibe.

Collapse
 
kling4ai-production profile image
Kling4 AI •

Local processing in the browser solves a lot of privacy headaches, but memory management around canvas contexts can get tricky with larger files. Good breakdown on the edge cases.

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

thanks

Collapse
 
nube_colectiva_nc profile image
Nube Colectiva •

Interesting thought, thanks for sharing 👍🏼

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

and thanks for reading too bro 🫡🔥

Collapse
 
hammiwor profile image
hammi •

the refresh-survival test hits hard. i once shipped a canvas editor that lost everything on reload and only found out from a user screenshot lol. solid checklist

Collapse
 
hoffmon profile image
Hoffmon •

Cool

Collapse
 
karima_kharkhache_05fdebc profile image
Karima Kharkhache •

Thanks 🙏🙏🙏🙂

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Your Welcome

Collapse
 
technogamerz profile image
𝐓𝐡𝐞 𝐋𝐚𝐳𝐲 𝐆𝐢𝐫𝐥 •

Intresting 🔥

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

🔥

Collapse
 
harrysstone profile image
Harry Stone •

Test 2 is the one I'd steal. Fixed timeouts are how we used to kill perfectly healthy downloads on slow lines, and stall-based abort is the right call — 176 MB on bad Wi-Fi is not a dead link.

One nit, IMO: the versioned cache name ('apic-models-v1') is the only thing standing between you and serving a stale model after a release. Bumping it by hand is exactly the kind of step that gets remembered for three releases and then forgotten, so it's probably worth deriving it from the model's own hash.

Collapse
 
naveed-ahmed profile image
Naveed Ahmed •

Test 1 is the one that got me — "falls back gracefully" on desktop turning into a silent hang on a phone is exactly the kind of bug that only shows up when you test on the real device. I also liked Test 2: switching from a fixed timeout to a stall-based one makes a lot of sense when model sizes range from 5 MB to 176 MB. Thanks for writing up the real failures instead of only the finished product.