DEV Community

Zihang Dong 董子航
Zihang Dong 董子航

Posted on

Shipping 28 browser-only image tools: 5 patterns from a quiet weekend project

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
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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' });
  });
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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}`;
Enter fullscreen mode Exit fullscreen mode
<!-- referencing HTML -->
<script src="/assets/js/i18n.js?v=1.8.3"></script>
Enter fullscreen mode Exit fullscreen mode

The pattern, every time:

  1. Edit the shared JS/CSS.
  2. Bump VERSION in the service worker.
  3. 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}`);
Enter fullscreen mode Exit fullscreen mode

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)