DEV Community

Faran Aiki
Faran Aiki

Posted on

How Disabling Javascript Leads me to a Higher Lighthouse Score for My Personal Website

A Little Introduction

Hi, I'm Faran Aiki, a Software Engineer & ITB Student. Recently, I decided to audit my personal portfolio website (faranaiki.id), and what I found was some ai-slop thingy that is slower than a snail.

But, before I mention about disabling JavaScripts, I am going to tell you some medium-optimizations & micro-optimizations for my personal website that I have went a month trying to make it good for Lighthouse & SEO.

1. Forced Reflows & Layout Thrashing

One of the most frustrating warnings in Lighthouse is "Avoid forced reflows" which to be honest somehow always appear.

My site has several highly interactive features, including a custom Python CLI, a tracking eye that follows the cursor, and guided tutorials. Behind the scenes, these components relied on getBoundingClientRect(), clientWidth, and scrollHeight.

The problem is that calling these DOM measurement APIs synchronously immediately after a React state change (or during mount) forces the browser to calculate the layout before it has even painted the screen. This causes the dreaded Layout Thrashing.

Some Ways of Fixing Things

For the CLI Auto-Scroll, The culprit was synchronously reading scrollHeight to update scrollTop inside useEffect. I fixed this by deferring the execution using the event loop (setTimeout).

Another issue is in the Sitemap Graph where the script is reading container.clientWidth inside useEffect. I refactored this to rely purely on ResizeObserver and utilized entry.contentRect.width, which provides the dimensions without thrashing the layout.

Lastly, the cursor tracking (in root) causes a problem, and that is immediate bounding box calculations. I deferred this using requestAnimationFrame to sync safely with the browser's native rendering cycle.

Code Example (CLI Auto-Scroll Fix):

useEffect(() => {
  // This is bad because it forces synchronous layout calculation before paint
  // terminalRef.scrollTop = terminalRef.scrollHeight;

  // This is good as it defers until after the browser paints
  setTimeout(() => {
    if (terminalRef) terminalRef.scrollTop = terminalRef.scrollHeight;
  }, 0);
}, [history]);
Enter fullscreen mode Exit fullscreen mode

2. "Avoid Unnecessarily Large Images"

Lighthouse flagged my mobile views for downloading oversized images (1080w) even though the images were displayed in tiny cards.

The Culprit is High Device Pixel Ratio (DPR).
I had initially set sizes="(max-width: 768px) 400px". On a mobile device with a 3x Retina display, the browser multiplies 400px * 3 = 1200px and happily requests the gigantic 1080w variant from Next.js. This is a bloat!

So, I Did "Dirty"

I tricked the browser by using viewport units. By switching to sizes="(max-width: 768px) 50vw", I signaled that the image only takes up half the screen. The math became 200px * 3 = 600px, forcing Next.js to serve the much lighter 640w or 750w chunks. This immediately saved huge amounts of bandwidth and slashed the LCP (Largest Contentful Paint).

3. Taming a 3.2s Main Thread Block (TBT)

On my [/sitemap-graph](https://faranaiki.id/en/sitemap-graph) route, I use react-force-graph-2d to render a complex interactive relationship graph of my entire website. Lighthouse yelled at me because a single JS chunk was monopolizing the CPU for 3.26 seconds, drastically inflating the Total Blocking Time (TBT).

The library utilizes D3 physics (flatMap, array physics calculation loops) which completely choked the main thread during initial hydration.

So, How I Reconstruct This?

I use client-side lazy loading: by dynamically importing the component and enforcing { ssr: false }, Next.js ships the lightweight skeleton structure immediately, letting the page become fully interactive (TTI) before it silently builds the heavy D3 canvas in the background.

import dynamic from 'next/dynamic';

// Heavy physics engine deferred entirely from the initial render path!
const SitemapGraphClient = dynamic(
  () => import('@/components/interactive/SitemapGraphClient'), 
  { ssr: false }
);
Enter fullscreen mode Exit fullscreen mode

4. Finally, Disabling JavaScript Solves a Problem

There is a problem because there is a "main thread hijacking" causing my canvas to be blank even though I expect texts to appear (server-side rendering).

Before that, to make the site feel premium, I used Lenis for custom momentum scrolling. However, my initial configuration was brutally expensive and came with a massive side effect.

How So?

I discovered the core issue using an old-school debugging technique: disabling JavaScript in the browser. Since I was using Next.js (Server-Side Rendering), I expected to see a slightly unstyled but fully readable HTML document. Instead, I was greeted with a completely blank canvas!

But Why?

My custom SmoothScroll wrapper component was waiting for the scroll library to initialize via useEffect before revealing its children. Without JavaScript, the children remained hidden forever. Furthermore, even with JavaScript, I was using complex mathematical easing functions (duration and cubic-bezier) running inside a manual requestAnimationFrame loop. Every single time a user scrolled, my custom code fought with the browser's native rendering pipeline. This leads to a huge TBT (Total Blocking Time) spikes.

Then, What did I do?

First, I ensured the content rendered immediately on the server, unconditionally, preventing the "blank canvas" trap. Then, I stripped out the heavy mathematical easing and replaced it with a lightweight lerp. I also handed the synchronization back to Lenis's native autoRaf parameter instead of manually binding it to React's lifecycle.

    // This is bad: hiding content until hydration & heavy math
    /*
    if (!isScrollInitialized) return null; // Created the Blank Canvas bug!

    const lenis = new Lenis({
      duration: 1.2,
      easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
    });
    */

    // Why this is preferred: this renders server-HTML immediately, uses lightweight lerp & native AutoRAF
    // return <>{children}</>; (Content is always visible)

    const lenis = new Lenis({
      lerp: 0.1, // Lightweight linear interpolation
      autoRaf: true, // Let the library handle its native performance loop
      syncTouch: false, // Let native touch handle mobile devices
    });
Enter fullscreen mode Exit fullscreen mode

This fix instantly solved the blank screen fallback, smoothed out the scrolling experience, and freed up the main thread to process real React state updates without stuttering.

Debug Bear screenshot of how great my website is

Conclusion

Yep, web optimization is a never-ending journey. From fixing layout thrashing to tricking Retina displays, every micro-optimization counts. But the biggest lesson I learned here is: never blindly trust your UI libraries to handle performance for you.

By simply turning off JavaScript, I found out that my fancy smooth-scrolling wrapper (which is AI-written & modified anyway) was actually hijacking the entire rendering pipeline. Reverting to lightweight native calculations (like native autoRaf and simple lerp) brought my Total Blocking Time back to life.

The next time your Next.js app, or even any website made using other frameworks, feels sluggish, do yourself a favor by disabling JS, opening the DevTools, and seeing what your HTML actually looks like naked.

Got any similar horror stories with React hydration or heavy animations? Let's discuss in the comments below! πŸ‘‡ [why did AI slop typed this question but whatever]

Top comments (0)