Five concrete patterns from building a no-upload, browser-local image toolkit — a single dispatcher for many converters, lazy-loaded decoders, transparency-safe JPG export, a subtle querySelector trap, and the cache-busting recipe that finally made hotfixes feel atomic.
For the past few weekends I've been quietly shipping a free, browser-local image toolkit called 24Picture. No uploads, no accounts, no servers — every conversion, resize, crop, watermark, and GIF builder runs on the user's own device.
The codebase is intentionally boring: plain HTML, ES2020 JavaScript, and Canvas. Nothing fancy. But after shipping 28 tools and ~30 supporting blog posts in five languages, a few patterns have proved themselves over and over — and a few traps have bitten me in ways that produced surprisingly weird bugs.
Here are five of those patterns. They're worth stealing if you're building anything that ships a lot of small browser tools, processes user files locally, or just wants to keep a healthy Lighthouse score without becoming a build-tooling project.
1. One shared dispatcher beats N copies of similar tool code
I originally hand-wrote a converter file per pair of formats. After the third one I gave up.
Now there's a single tool-page.js that holds a tiny route → format-matrix table:
const ROUTE_MATRIX = {
'webp-to-png': { input: ['image/webp'], outputMime: 'image/png', ext: 'png' },
'webp-to-jpg': { input: ['image/webp'], outputMime: 'image/jpeg', ext: 'jpg' },
'png-to-jpg': { input: ['image/png'], outputMime: 'image/jpeg', ext: 'jpg' },
'tiff-to-jpg': { input: ['image/tiff'], outputMime: 'image/jpeg', ext: 'jpg', decoder: decodeTiff },
'tiff-to-png': { input: ['image/tiff'], outputMime: 'image/png', ext: 'png', decoder: decodeTiff },
'ico-to-png': { input: ['image/x-icon', 'image/vnd.microsoft.icon'],
outputMime: 'image/png', ext: 'png', decoder: decodeIco },
// …28 entries
};
Each tool page sets <body data-route="tiff-to-jpg"> and the dispatcher looks up its row at boot. Adding a new tool is one line + one tool HTML page.
The real win isn't lines saved — it's that the upload zone, preview, MIME validation, batch download UX, drag-drop hints, and download naming convention all live in one place. No duplicated bug fixes. When you find a UX flaw, you fix it 28 times by editing one file.
2. Lazy-load heavy decoders only on the pages that need them
Some browser image libraries are huge:
| Library | Size | When you actually need it |
|---|---|---|
heic2any |
~1.3 MB | only on HEIC tools |
UTIF.js + pako_inflate
|
~79 KB | only on TIFF tools |
icojs |
~11 KB | only on the ICO tool |
If I had loaded all of these globally on every page, the homepage would have paid a 1.5 MB tax for a feature 95% of users will never touch.
Instead, each tool HTML pulls only its own decoder:
<!-- tools/tiff-to-jpg.html -->
<script src="/assets/vendor/pako/pako_inflate.min.js"></script>
<script src="/assets/vendor/utif/UTIF.js"></script>
<script src="/assets/js/tool-page.js?v=1.8.0"></script>
The shared dispatcher only invokes a decoder when its row declares one. The rest of the site stays decoder-free.
If you go this route, host the libraries yourself (/assets/vendor/<lib>/…) rather than relying on a third-party CDN. The whole point of a "no upload, runs locally" tool is undermined the moment you start fanning out to ten random origins on first paint.
3. Always flatten transparency before exporting JPG
This one is mundane but I caught myself making the mistake twice. JPG has no alpha channel, so if you draw a PNG with transparent areas straight to a canvas and call toBlob('image/jpeg'), you get black, not white, in most browsers.
The fix is one extra rectangle:
function exportJpg(srcCanvas) {
const c = document.createElement('canvas');
c.width = srcCanvas.width;
c.height = srcCanvas.height;
const ctx = c.getContext('2d');
// Paint white first — transparent source pixels otherwise become black in JPG.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(srcCanvas, 0, 0);
return new Promise(res => c.toBlob(res, 'image/jpeg', 0.92));
}
Wrap it in a single helper and call it from every *-to-jpg tool. Pick a quality (0.85–0.92 is a safe sweet spot for photos), and stop worrying about it.
4. Don't pass href straight into querySelector for hash anchors
This bug shipped to production for several days before I noticed it. The site has a /changelog/ page with version IDs like v1.7.2. The smooth-scroll handler was:
// 🔥 will throw on click for ANY hash containing a dot
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const t = document.querySelector(a.getAttribute('href'));
if (t) t.scrollIntoView({ behavior: 'smooth' });
});
});
Clicking the sidebar #v1.7.2 link blew up the page with:
Uncaught SyntaxError: Failed to execute 'querySelector' on 'Document':
'#v1.7.2' is not a valid selector.
In CSS, . is a class delimiter, so #v1.7.2 is parsed as "element with ID v1 that also has classes 7 and 2" — structurally invalid.
Two fixes that always work:
// Preferred: skip CSS entirely for hash anchors
const t = document.getElementById(href.slice(1));
// Or, if you really need a selector, escape it
const t = document.querySelector('#' + CSS.escape(href.slice(1)));
I now grep the whole codebase for querySelector(.*getAttribute\(.href before every release, and the rule is written into our skill docs so it never sneaks back in.
5. When you change a shared asset, bump the Service Worker AND add ?v=
The site's Service Worker uses stale-while-revalidate for static assets — fast for users, but it means an old cached i18n.js can hang around for a long visit. Worse, edge / origin caches can hold on to the path-without-query independently.
The cheapest belt-and-suspenders recipe:
// public/service-worker.js
const VERSION = 'v1.8.3'; // bump this on shared asset changes
const SHELL_CACHE = `app-shell-${VERSION}`;
const RUNTIME_CACHE = `app-runtime-${VERSION}`;
<!-- referencing HTML -->
<script src="/assets/js/i18n.js?v=1.8.3"></script>
The pattern, every time:
- Edit the shared JS/CSS.
- Bump
VERSIONin the service worker. - Update the
?v=query string on every page that references the file.
The SW bump invalidates RUNTIME_CACHE on next activate. The ?v= busts any intermediate cache by changing the URL. Either one alone leaves a long tail of stale clients; the two together are what finally made hotfixes feel atomic on a multi-language static site.
Bonus tip: also wire the SW registration itself to a versioned URL so a stale /service-worker.js response can't keep an old SW alive:
// pwa-install.js
const SW_VERSION = '1.8.1';
navigator.serviceWorker.register(`/service-worker.js?v=${SW_VERSION}`);
Wrap
None of these patterns are clever. They're just the boring tools that have actually kept the project moving on weekend after weekend, while shipping 28 tools across 5 languages without a backend.
If you want to see them in production, the whole toolkit is browser-local and free, and the changelog literally documents the version where each of these patterns was learned the hard way (including v1.8.2, the "querySelector hash" release).
Happy shipping — and may all your weekend projects compile in the browser. 🛠️
Top comments (0)