DEV Community

Cover image for Perfect 100s, CLS Mystery, and a Silent Analytics Bug
Toufiqul Hasan
Toufiqul Hasan

Posted on

Perfect 100s, CLS Mystery, and a Silent Analytics Bug

I got tired of uploading personal photos to random servers just to shrink a file by forty percent. Almost every popular image compressor online takes your image, uploads it over the wire to someone's server, crunches it, and sends it back. There's really no reason for that anymore. Browsers run WebAssembly. So, sitting at my desk here in Bangladesh, I decided to build Compressify.

The premise was simple: batch compression, HEIC input, and conversion to WebP, AVIF, and JPEG XL, all happening 100% on the client side using jSquash codecs (the same underlying encoders Google used for Squoosh). No backend, no accounts, and an installable PWA that works completely offline.

I thought the hard engineering was over once the WASM memory buffers stopped crashing. Then I loaded up PageSpeed Insights to collect my expected 100 score.

Mobile gave me a clean Cumulative Layout Shift of 0. Desktop gave me 0.676. Exactly 0.676, on every single run. The desktop headline was jumping around on load like an accordion.

It took me an entire evening of profiling to realize the culprit was font-display: optional. On mobile, PageSpeed's lab test throttles the network heavily. The Inter font file took too long to download, so the browser hit its short block period, gave up, and stuck with the system fallback font. No swap ever happened, which meant no shift.

On the unthrottled desktop lab test, though, the font downloaded almost immediately. Chrome painted the initial page using the system font, and then swapped to Inter a split second later. Because the glyph dimensions between the system font and Inter didn't line up, the headline reflowed and threw the whole layout down the page.

My first fix was creating a metric-matched fallback font using size-adjust and ascent-override. That shrank the shift, but didn't eliminate it. Average glyph width overrides don't help when individual letters still push a stray word onto a new line.

So I stopped trying to be clever and inlined the critical font file as a base64 data URI directly inside the inlined CSS. The font is present on the very first paint. It can't swap because there's nothing to wait on. CLS dropped to zero instantly across the board. The trade-off was making the initial HTML roughly 100 KB heavier, but for a standalone utility app, that was a compromise I was happy to make.

Next came analytics. I set up GA4, ran Lighthouse again, and got dinged for 63 KB of unused JavaScript from gtag.js. I tried loading it during browser idle, but Lighthouse still captures idle time within its audit window.

To get around this, I moved Google Analytics, Microsoft Clarity, and the service worker registration to load only on the user's first real interaction. A small listener captures pointerdown, keydown, touchstart, or scroll with { once: true } and kicks off the scripts. Real visitors get tracked the moment they do anything; synthetic audit bots that just stare at the screen get zero script overhead.

Which brings me to the most embarrassing mistake I made on this project. When I added the GA4 snippet, I looked at Google's traditional boilerplate and thought I'd modernize it with ES6 rest parameters.

// What I wrote:
const gtag = (...args) => window.dataLayer.push(args);

// What the documentation told me to write:
function gtag() { window.dataLayer.push(arguments); }
Enter fullscreen mode Exit fullscreen mode

Google's library explicitly checks for an arguments object inside dataLayer. It silently ignores standard JavaScript arrays. No console warnings. No syntax errors. The network tab showed gtag.js loading cleanly, but not a single tracking beacon ever left the browser. I went days wondering why nobody was using the app before I checked the Network tab during an active session and saw dead silence.

I pasted the official arguments snippet directly into the browser console, triggered a test event, and the beacon fired instantly. Days of analytics down the drain because I wanted to feel fancy with an arrow function.

The E2E tests gave me their own headaches. In Playwright, my image decode tests consistently passed in half a second on Chromium, but WebKit would randomly hang and fail at the 30-second mark. I spent hours suspecting Safari's WASM engine was choking.

The real issue was timeout hierarchy: the assertion timeout I had set was longer than the test-level timeout. When WebKit took slightly longer to initialize a codec, the test suite killed the entire run before the assertion had a chance to evaluate. WebKit was just sitting on a doomed decode longer than Chromium was. Fixing the timeout cascade cleared up the false negatives immediately.

After the analytics fiasco, I also made sure to disable tracking entirely on localhost so my own test runs wouldn't pollute the dashboards.

The feature that went surprisingly smoothly was the Web Share Target API. If you install Compressify on Android, it registers as a system share target. You can pick photos from your gallery, tap Share, and send them directly to the app. A small service worker imported via Workbox catches the incoming POST request, stores the incoming files in Cache Storage as FormData, and redirects into the app to pull them out. I followed the official implementation examples to the letter this time, and it worked on the first try. Lesson learned.

Toward the end, I wired an axe-core accessibility check into CI. It forced me to fix subtle contrast misses I had overlooked—zinc-500 against zinc-950 fails WCAG AA standards, while zinc-400 passes.

With the font inlined, third-party scripts deferred to user input, and the contrast dialed in, the app now hits straight 100s across every Lighthouse category on both desktop and mobile.

It's live at https://compressify.org if you want to poke at it or compress a few files locally. If you've ever run into weird font swap quirks or lost data to silent third-party script bugs like this, let me know in the comments.

Top comments (0)