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,
});
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;
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;
}
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;
}
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:
- Take a region around the mask (at least 512 native pixels when the photo allows).
- Run the model on just that crop.
- 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".
- 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)}`);
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
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
}
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' }))
);
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)
Nice Post!!
Fastest reading π π€£
nope...
1st --> comment on post
2nd --> read the post
lol
Okay this thread looks good...
yea... very clean
Lol I can't use gif I'm missing my signature meme
I'll be back
Bro lol you're reminding me my memes
π€£ @puneetkumar2010 gone
Sad π Puneet Kumar is no more......
Online now, he will come after a while
Hehehe
I also want to go cuz I have work :(
Okay then Bye
Oh really?
Yeah
Ok bye! πππππ€
Hmm
ππββοΈ
Bro look this isn't it cool?
What is use or that?
It's TTS bro we can use it to convert text to speech.
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 π
Bro I didn't make it lol π€¦ Somebody else did. I just shared it with you
Oh ui opened in 1 4th of screen so I didn't understood that much
Ohhh why is that lol
Bro idk π€·ββοΈ π
π
π
Puneet bhaiya wapas aagye yee
π€£π€£
π€£π€£
Sly guy hehe
Puneet iz backkkkk
π€£
Legend never dies
so we are immortal... π
Yeah we will be remembered forevrr
yea!
Redirected!
π€£ bruhh
Peak cinema π₯
Okay guys bye.
ππ€
bye bye didi
Bye bye @ everyone
Bye Bye good night
Bye bye by the way you have a message in vc
Good night kaha hai bekar Night.
Mental stress of IITian
vc? anyway see you tomorrow! π΄
now I am truly gone okay?
Ugh it's only 8:00?
Oh then let's do something else...
Yeah! Stress π¬
Oh this candy is available there as well?!
Yeah,It is available in Hyderabad.
π―
It is available all over India. One rupees π€£
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, andnavigator.storage.estimate()tells you quota and usage before you start the 208 MB object remover. Checking the estimate first beats hitting aQuotaExceededErroratcache.put()after every byte already arrived. Chrome grants persist() on its own heuristics, Firefox may ask the user, so treat it as a request.Yeah you're actually right I never thought of that it is a request
Got Python badge in this legendary post: dev.to/akhourianmolkumar/are-you-a...
Hope I'll get java script badge in this one... π₯π₯π₯
π
π
Intresting π₯
π₯
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.
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?