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
Akhouri Anmol Kumar
Akhouri Anmol Kumar

Posted on

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

Description: On-device AI, WASM threads, target-size compression, inpainting and 3D LUTs in a single HTML file. 7 real lessons from building APIC Web.

Most web developers can build a form, fetch an API and center a div.

Browser experts know what the browser refuses to do, what happens on a phone instead of your laptop, and how to ship real software with no server at all.

I found out which one I was while building APIC Web: a free photo editor, background remover, object remover, converter and compressor where your photos never leave your device. No upload queue, no backend, and the whole thing is one HTML file hosted on GitHub Pages.

Every test below is a real bug or design decision from that build. Score yourself honestly. πŸ‘‡


Test 1: Why did my AI step silently never finish on phones?

APIC runs AI models in the browser with ONNX Runtime Web (WebAssembly). Multi-threaded WASM needs SharedArrayBuffer, and that needs special response headers (COOP/COEP). A plain static host like GitHub Pages doesn't send them, so crossOriginIsolated is false.

On desktop, the runtime usually degrades gracefully. On phones it often doesn't, and the AI step just hangs with no error. The fix was to stop relying on the fallback:

// Static hosts can't send COOP/COEP, so threads aren't available.
// Force the one configuration that behaves the same everywhere.
ort.env.wasm.numThreads = 1;
ort.env.wasm.proxy = false;

const session = await ort.InferenceSession.create(modelBytes, {
  executionProviders: ['wasm'],
  enableCpuMemArena: false,
  enableMemPattern: false,
});
Enter fullscreen mode Exit fullscreen mode

Where headers are available, APIC checks self.crossOriginIsolated before asking for more than one thread.

Developer: "It works on my laptop."
Expert: knows that "falls back gracefully" is a claim to test on a real phone, not a promise.


Test 2: Can you tell a slow download from a dead one?

APIC's AI models are big: 5 MB (Lite), 44 MB (Fast), 176 MB (Maximum) for background removal, and about 208 MB for the highest-quality object remover. A fixed 30-second timeout would kill every slow phone connection.

So the timeout is stall-based: abort only if no bytes arrive for 40 seconds. A huge model on bad Wi-Fi is allowed to take as long as it needs. A dead link is not. After that it goes into the Cache API so the second run is instant:

const CACHE = 'apic-models-v1';
const cache = await caches.open(CACHE);

let hit = await cache.match(url);
if (hit) return hit.arrayBuffer();           // cached: no download

const bytes = await downloadWithStallTimeout(url);   // abort only if the stream goes quiet
await cache.put(url, new Response(bytes));   // wrapped in try/catch: private mode & quota can fail
return bytes;
Enter fullscreen mode Exit fullscreen mode

And if a model can't load at all, the tool steps down instead of failing: Ultra (LaMa) β†’ MI-GAN β†’ a quick patch fill.

Developer: fetch() with a timeout.
Expert: measures progress, caches the result, and always has a plan B.


Test 3: Can your app hit "under 200 KB" without guessing?

Upload forms say "max 200 KB" and users play quality-slider roulette. APIC's Compress tool does a binary search on JPEG quality instead:

async function findQualityForTarget(canvas, targetBytes) {
  let lo = 0.05, hi = 0.98, best = null;
  for (let i = 0; i < 7; i++) {
    const mid = (lo + hi) / 2;
    const blob = await toBlob(canvas, 'image/jpeg', mid);
    if (blob.size <= targetBytes) { best = { blob, q: mid }; lo = mid; }  // fits: try higher quality
    else { hi = mid; }                                                   // too big: go lower
  }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

Seven encodes find the highest quality that still fits. If even the lowest quality is too big, APIC shrinks the image by about 20% and searches again, up to 8 times. If it still can't make it, it tells you the real size instead of pretending.

Developer: quality = 0.7 and hope.
Expert: turns a guess into a search and is honest when the answer is "can't".


Test 4: Why did my transparent PNG turn black as a JPEG?

JPEG has no alpha channel. When a canvas with transparent pixels is encoded as JPEG, those pixels come out black. After background removal (which creates lots of transparency), that's a nasty surprise.

The fix is to paint white first, then draw the image on top:

function flattenToWhite(src) {
  const out = document.createElement('canvas');
  out.width = src.width; out.height = src.height;
  const ctx = out.getContext('2d');
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, out.width, out.height);
  ctx.drawImage(src, 0, 0);
  return out;
}
Enter fullscreen mode Exit fullscreen mode

APIC also tells you which formats keep transparency (PNG, WebP) and which don't, right in the Convert panel.

Developer: assumes a canvas is just pixels.
Expert: knows every output format has rules about alpha.


Test 5: Do you process the whole image, or only what changed?

Removing an object means running an inpainting model (LaMa, a Fourier-convolution network, or the lighter MI-GAN). LaMa works on a fixed 512Γ—512 window, so feeding it a 12-megapixel photo would be slow and blurry.

APIC only processes the area around your brush:

  1. Take a region around the mask (at least 512 native pixels when the photo allows).
  2. Run the model on just that crop.
  3. Feather the edge with box blurs, colour-match the fill to its surroundings, and re-add film grain so it doesn't look "too clean".
  4. Paste it back.

Small edit, small compute, and the rest of your photo is untouched.

Developer: runs the model on the full image.
Expert: shrinks the problem first, then blends the result back so nobody can see the seam.


Test 6: Can you export your whole colour grade as a .cube LUT?

APIC has curves, an 8-band HSL mixer and 3-way colour grading. People wanted to use those grades in DaVinci Resolve or Premiere. A .cube file is a 3D lookup table: for every input colour, what colour comes out.

The trick: you don't need to reverse-engineer your pipeline. Feed an identity grid of colours through the same pixel function, and write down what comes out:

const N = 33;                       // 33Γ—33Γ—33 lattice
const buf = new Uint8ClampedArray(N * N * N * 4);
let i = 0;
for (let b = 0; b < N; b++)
  for (let g = 0; g < N; g++)
    for (let r = 0; r < N; r++) {   // red changes fastest, as .cube requires
      buf[i++] = Math.round(r / (N - 1) * 255);
      buf[i++] = Math.round(g / (N - 1) * 255);
      buf[i++] = Math.round(b / (N - 1) * 255);
      buf[i++] = 255;
    }

processPixels(buf, gradeWithoutSpatialEffects);   // same code that edits your photo

const lines = ['TITLE "APIC grade"', 'LUT_3D_SIZE 33',
               'DOMAIN_MIN 0.0 0.0 0.0', 'DOMAIN_MAX 1.0 1.0 1.0', ''];
for (let j = 0; j < buf.length; j += 4)
  lines.push(`${(buf[j]/255).toFixed(6)} ${(buf[j+1]/255).toFixed(6)} ${(buf[j+2]/255).toFixed(6)}`);
Enter fullscreen mode Exit fullscreen mode

One catch: a LUT can only hold colour operations. Clarity, sharpen, vignette and grain depend on neighbouring pixels, so they're excluded and the UI says so.

Developer: exports a screenshot of the look.
Expert: samples the function itself.


Test 7: Why does your resized image look soft?

ctx.drawImage(img, 0, 0, smallW, smallH);   // one big jump: soft, aliased
Enter fullscreen mode Exit fullscreen mode

Shrinking a large image in one drawImage call looks muddy. APIC's Resize tool halves the image step by step until it's within 2Γ— of the target, then does one final pass:

function highQualityResize(src, targetW, targetH) {
  let cur = src, w = src.width, h = src.height;
  while (w > targetW * 2 || h > targetH * 2) {
    const nw = Math.max(targetW, Math.round(w / 2));
    const nh = Math.max(targetH, Math.round(h / 2));
    const step = document.createElement('canvas');
    step.width = nw; step.height = nh;
    const c = step.getContext('2d');
    c.imageSmoothingEnabled = true;
    c.imageSmoothingQuality = 'high';
    c.drawImage(cur, 0, 0, nw, nh);
    cur = step; w = nw; h = nh;
  }
  // ...then one last high-quality pass onto the exact target size
}
Enter fullscreen mode Exit fullscreen mode

Developer: drawImage once and move on.
Expert: knows the order of operations changes the result.


Bonus: How do you ship a Web Worker inside a single HTML file?

No build step, no separate worker.js. APIC keeps the worker's source as a string, turns it into a Blob and starts it from a Blob URL:

const SRC = `/* worker code as a string */`;
const worker = new Worker(
  URL.createObjectURL(new Blob([SRC], { type: 'application/javascript' }))
);
Enter fullscreen mode Exit fullscreen mode

Heavy inpainting runs off the main thread, and the whole app is still one file you can host anywhere.


Try it yourself

APIC is built by Akhouri Systems. It runs in your browser, and your images are never uploaded. The only thing the browser downloads is the AI model files (from Hugging Face), cached after the first run. Being upfront about the trade-offs: editing needs no account, you sign in with one tap on Google only when you export, and the web version shows one small banner ad to stay free. The offline PC app has none.

App What it is Lessons it proves Get it
πŸ–ΌοΈ APIC Web Edit, pro colour tools, remove objects, remove backgrounds with AI, resize, convert, compress, 15-network social presets, batch ZIP Tests 1-7 Open APIC Web
πŸ’» APIC for PC Windows version, runs fully offline, no account The desktop twin Download (zip)

If you're learning how the browser works outside a tutorial, open it, drop a photo, then open DevTools and see what's going on under the hood. That's the fastest way I know to level up.


Your score

  • 0-2 tests known: You're a web developer. Great start, now break something on a real phone.
  • 3-5: Solid. You've shipped something real.
  • 6-7: You're a browser expert. Come argue with me in the comments.

Which test did you fail first? Mine was [the AI step that worked on desktop and silently hung on phones]. Tell me yours below πŸ‘‡

Top comments (70)

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

Thread Thread
 
effessdev profile image
EffessDev •

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

I'll be back

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Bro lol you're reminding me my memes

Thread Thread
 
effessdev profile image
EffessDev •

🀣 @puneetkumar2010 gone

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Sad πŸ˜” Puneet Kumar is no more......

Online now, he will come after a while

Thread Thread
 
effessdev profile image
EffessDev •

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Hehehe

Thread Thread
 
effessdev profile image
EffessDev •

I also want to go cuz I have work :(

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Okay then Bye

Thread Thread
 
effessdev profile image
EffessDev •

Oh really?

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Yeah

Thread Thread
 
effessdev profile image
EffessDev •

Ok bye! πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ€

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Hmm

Thread Thread
 
effessdev profile image
EffessDev •

πŸ‘‹πŸ™‹β€β™‚οΈ

Thread Thread
 
effessdev profile image
EffessDev •

Bro look this isn't it cool?

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

What is use or that?

Thread Thread
 
effessdev profile image
EffessDev •

It's TTS bro we can use it to convert text to speech.

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Woahhh what due to its UI first impression I failed to understand πŸ˜” πŸ˜•
Bro bruh you are making these good products when I can use laptop and use it, test it lol πŸ˜†

Thread Thread
 
effessdev profile image
EffessDev •

Bro I didn't make it lol 🀦 Somebody else did. I just shared it with you

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Oh ui opened in 1 4th of screen so I didn't understood that much

Thread Thread
 
effessdev profile image
EffessDev •

Ohhh why is that lol

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Bro idk πŸ€·β€β™‚οΈ 😐

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

πŸ‘€

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

πŸ‘€

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Puneet bhaiya wapas aagye yee

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

🀣🀣

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

🀣🀣

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Sly guy hehe

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

Puneet iz backkkkk

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

🀣

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Legend never dies

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

so we are immortal... πŸ™„

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Yeah we will be remembered forevrr

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

yea!

Thread Thread
 
effessdev profile image
EffessDev •

Redirected!

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

🀣 bruhh

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Peak cinema πŸŽ₯

Thread Thread
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Okay guys bye.

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

πŸ™ŒπŸ€ 

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

bye bye didi

Thread Thread
 
effessdev profile image
EffessDev •

Bye bye @ everyone

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

Bye Bye good night

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Bye bye by the way you have a message in vc

Thread Thread
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Good night kaha hai bekar Night.

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Mental stress of IITian

Thread Thread
 
effessdev profile image
EffessDev •

vc? anyway see you tomorrow! 😴

Thread Thread
 
effessdev profile image
EffessDev •

now I am truly gone okay?

Thread Thread
 
effessdev profile image
EffessDev •

Ugh it's only 8:00?

Thread Thread
 
effessdev profile image
EffessDev •

Oh then let's do something else...

Thread Thread
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ • • Edited

Thread Thread
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Yeah! Stress 😬

Thread Thread
 
effessdev profile image
EffessDev •

Oh this candy is available there as well?!

Thread Thread
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Yeah,It is available in Hyderabad.

Thread Thread
 
effessdev profile image
EffessDev •

😯

Thread Thread
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

It is available all over India. One rupees 🀣

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
 
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
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Intresting πŸ”₯

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?