Top Organic Leads

Reduce Unused JavaScript: PageSpeed 86 to 93

Your images are optimized. The remaining bottleneck is JavaScript. Here's how to cut it and gain 7–10 PageSpeed points.

Live Documentβ€” This guide is continuously updated as we optimize real client sites.

Last updated: May 30, 2026 at 7:46 PM UTC

AI-agent ready. Share this article with your coding agent and say: "Read this guide, run the detect commands on my project, and create an implementation plan for the fixes that apply."

You've done the basics β€” fixed image priorities, added sizes props, corrected aspect ratios. You're at 85-86. But PageSpeed is still flagging "Minimize main-thread work" and "Reduce unused JavaScript."

The problem is no longer images. It's the JavaScript you're shipping to the browser.

On one production Next.js site, we removed 327KB of transferred JavaScript (804KB uncompressed) without changing a single user-facing feature. Here's exactly what we cut and how.

The JavaScript Audit

Library RemovedTransfer SavedUncompressed
zod (dead schema code)61.6 KB~96 KB
gtag.js β†’ server-side GA4171.7 KB~171 KB
libphonenumber-js β†’ regex validator43.8 KB149 KB
8 unused packages~50 KB~388 KB
Total~327 KB~804 KB

Each of these was a real dependency in package.json that was either unused, replaceable, or moveable to the server.

1. Dynamic Import Heavy Client-Side Libraries

The problem: Static import statements include the library in the page's JavaScript bundle, even if the component using it is below the fold or rarely interacted with.

ReactPlayer (~80KB)

// Before β€” 80KB added to every page that imports this component:
import ReactPlayer from "react-player";

// After β€” loads only when the component renders:
import dynamic from "next/dynamic";
const ReactPlayer = dynamic(() => import("react-player"), { ssr: false });

Contact forms with zod + react-hook-form (~60KB)

// Before:
import Contact from "@/molecules/contact";

// After β€” form JS loads only when footer scrolls into view:
import dynamic from "next/dynamic";
const Contact = dynamic(() => import("@/molecules/contact"), { ssr: true });

The rule:

  • Use ssr: false for client-only libraries (video players, charts, maps)
  • Use ssr: true for components that should still appear in the server-rendered HTML (forms, content sections) β€” the HTML is immediate, only the JS hydration is deferred

GSAP Animations

// Load GSAP lazily inside useEffect β€” NOT at module scope
useEffect(() => {
    import("gsap").then(({ gsap }) => {
        gsapRef.current = gsap;
        // ... initialization
    });
}, []);

2. Remove Unused Dependencies

Many projects accumulate dependencies over time that are never actually imported.

Detection script:

# For each dependency in package.json, check if it's actually imported
cat package.json | grep -oP '"@?[a-z][-a-z/]*"' | while read pkg; do
  pkg_clean=$(echo $pkg | tr -d '"')
  count=$(grep -rl "$pkg_clean" . --include="*.tsx" --include="*.ts" 2>/dev/null | grep -v node_modules | grep -v ".next" | wc -l)
  [ "$count" -eq 0 ] && echo "UNUSED: $pkg_clean"
done

What we found and removed on one project:

PackageSizeReason Unused
motion (framer-motion)~148KBNo imports found anywhere
lenis~30KBSmooth scroll lib, replaced with native CSS
keen-slider~25KBReplaced with custom carousel
ogl~80KBWebGL lib, never imported
cheerio~50KBHTML parser, no imports
turndown~20KBHTML→Markdown, no imports

Fix: pnpm remove motion lenis keen-slider ogl cheerio turndown

3. Remove Dead Validation Libraries

The subtle one. A contact form imports zod (~96KB) for schema definition, but the actual validation is done by react-hook-form's inline validate functions. The zod schema is defined but never passed to zodResolver β€” it's dead code that gets bundled anyway.

Detection:

# Check if zod is actually used for runtime validation
grep -rn "zodResolver\|safeParse\|parse(" molecules/contact.tsx

If there are no results, zod is being used only for types.

Fix: Replace z.infer<typeof schema> with a manual TypeScript type:

// Before (pulls in entire zod library at runtime):
import { z } from 'zod';
const schema = z.object({ name: z.string(), email: z.string() });
type FormData = z.infer<typeof schema>;

// After (zero runtime cost β€” TypeScript types are erased at compile time):
type FormData = { name: string; email: string; phone: string; message: string };

Keep zod in server-side API routes (app/api/contact/route.ts) β€” server code doesn't affect client bundle size.

4. Replace libphonenumber-js with a Lightweight Validator

libphonenumber-js is 149KB uncompressed (43.8KB transferred) just to validate US phone numbers. It includes metadata for every country's phone format.

The replacement: A ~1KB regex-based US validator:

function digitsOnly(phone: string): string {
    return phone.replace(/\D/g, '');
}

export function validatePhoneNumber(phone: string): { valid: boolean; message?: string } {
    if (!phone?.trim()) return { valid: false, message: 'Phone number is required' };
    const digits = digitsOnly(phone);

    if (digits.length === 11 && digits.startsWith('1'))
        return validateUSDigits(digits.slice(1));
    if (digits.length === 10)
        return validateUSDigits(digits);

    return { valid: false, message: 'Enter a valid 10-digit US phone number' };
}

function validateUSDigits(d: string): { valid: boolean; message?: string } {
    if (d[0] === '0' || d[0] === '1') return { valid: false, message: 'Invalid area code' };
    if (d[3] === '0' || d[3] === '1') return { valid: false, message: 'Invalid phone number' };
    if (d[1] === '1' && d[2] === '1') return { valid: false, message: 'Invalid area code' };
    return { valid: true };
}

The existing validatePhoneNumber function signature is unchanged β€” all call sites work without modification. Then: pnpm remove libphonenumber-js

5. Replace Client-Side gtag.js with Server-Side Analytics

The biggest single win: 171KB removed.

Traditional gtag.js loads Google's entire analytics library in the browser. Even with lazyOnload, the script still loads during PageSpeed's test window and is flagged as "unused JavaScript."

Our replacement: ~1KB total client code.

The flow:

Browser β†’ navigator.sendBeacon('/api/analytics') β†’ Server β†’ GA4 Measurement Protocol

The client module creates a client ID (stored in a cookie), captures a session ID, and sends events to your own /api/analytics endpoint. The server-side route forwards them to GA4 β€” authenticated with an API secret that never touches the browser.

Wiring it up in layout.tsx

After creating the client analytics module and the API route, update layout.tsx:

// REMOVE these:
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" strategy="lazyOnload" />
<Script id="ga4-init" strategy="lazyOnload">...</Script>

// ADD:
import Analytics from "@/atoms/analytics";
// In <body>:
<Analytics />

This is the step most teams forget β€” the old <Script> tags must be deleted, not just commented out. If both exist, you ship 171KB of gtag.js and your new 1KB client module.

⚠️ What's lost: Automatic scroll depth, outbound click tracking, enhanced measurement, and Google Ads remarketing are not preserved by the Measurement Protocol approach. Page views, sessions, and form submissions are preserved. For lead generation sites, these are the metrics that matter β€” but if you rely on remarketing audiences or enhanced measurement events, evaluate the tradeoff before migrating.

Verify: After deploying, check GA4 Realtime β€” page views should appear within 30 seconds.

6. Eliminate Forced Reflows

PageSpeed diagnostic: "Forced reflow β€” [unattributed] Xms"

Forced reflows happen when JavaScript writes to the DOM (.style.opacity) and then reads layout properties (.offsetHeight) in a loop. The browser must recalculate layout between each operation.

Replace JS inline style writes with CSS classes

/* globals.css β€” define states in CSS */
.animated-element {
    opacity: 0;
    transform: translateY(60px) scale(0.95);
    transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
                transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.animated-element--visible {
    opacity: 1;
    transform: translateY(0) scale(1);
}
// Toggle class instead of writing inline styles
useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
        if (entry.isIntersecting) {
            cards.forEach((card, i) => {
                card.style.transitionDelay = `${i * 0.06}s`;
                card.classList.add('animated-element--visible');
            });
            observer.disconnect();
        }
    }, { threshold: 0.05 });
    observer.observe(container);
    return () => observer.disconnect();
}, []);

Remove scrollBehavior manipulation

// Before (forced reflow every auto-rotate):
container.style.scrollBehavior = 'auto';
void container.offsetHeight; // forces reflow
container.style.scrollBehavior = '';
container.scrollTo({ left: newScrollLeft, behavior: 'smooth' });

// After (zero reflow):
container.scrollTo({ left: newScrollLeft, behavior: 'smooth' });

Defer initial layout reads

// Before (reads layout during hydration):
useEffect(() => {
    updateSizes(); // synchronous layout read
    const ro = new ResizeObserver(updateSizes);
}, []);

// After (deferred to after paint):
useEffect(() => {
    requestAnimationFrame(updateSizes);
    const ro = new ResizeObserver(updateSizes);
}, []);

7. Remove Blanket CSS Rules

Rules like section[class*="bg-"] { perspective: 1000px; backface-visibility: hidden; } create a new compositing layer for every matching section β€” massive Style & Layout cost.

Fix: Delete the blanket rule. Only apply will-change, backface-visibility, or perspective to elements that are actively animating.

8. Add content-visibility: auto for Below-Fold Sections

content-visibility: auto tells the browser to skip layout and paint for sections that are far below the fold. This can dramatically reduce Style & Layout time on initial load.

/* globals.css */
.content-lazy {
    content-visibility: auto;
    contain-intrinsic-size: auto 600px;
}
<Hero />           {/* Above fold β€” NO content-lazy */}
<Testimonials />   {/* LCP element β€” NO content-lazy */}

{/* Below-fold sections skip layout until near viewport */}
<div className="content-lazy"><WhoWeAre /></div>
<div className="content-lazy"><Solutions /></div>
<div className="content-lazy"><ForWho /></div>
<div className="content-lazy"><Footer /></div>

Never apply content-lazy to the hero section, LCP element, sticky elements, or elements with position: fixed children.

β€œ
🚨 Critical caveat: is incompatible with PageSpeed. PageSpeed's Speed Index is calculated from screenshots taken during a simulated scroll. Sections deferred by appear as blank rectangles in those screenshots β€” Speed Index explodes (e.g., 6.1s). Additionally, PageSpeed's scroll simulation triggers deferred rendering as main-thread Style & Layout spikes during the test window β€” TBT spikes (e.g., 650ms). A real-world 100/100 site collapsed to 72 after adding to 5 below-fold sections.
is a real-user optimization only. It improves actual rendering for humans who scroll, but is fundamentally incompatible with PageSpeed's screenshot-based Speed Index measurement. If your goal is a high PageSpeed score, do not use this technique.

9. Defer Dynamic Script Insertion (CallRail, etc.)

Call tracking scripts like CallRail (swap.js or proxy endpoints) use DOM mutations to find and swap phone numbers. If loaded with a raw <script src="..." defer /> tag, the script executes during the critical hydration phase, blocking the main thread and delaying element rendering.

Fix: Use requestIdleCallback inside a "use client" component to load the script as soon as the browser is idle after LCP β€” no user interaction required:

'use client';
import { useEffect, useRef } from 'react';

export default function CallRailLoader() {
    const loaded = useRef(false);

    useEffect(() => {
        const load = () => {
            if (loaded.current) return;
            loaded.current = true;

            const script = document.createElement('script');
            script.src = '/api/callrail-swap/';
            script.async = true;
            document.head.appendChild(script);
        };

        // requestIdleCallback fires ~50-200ms after LCP.
        // Lighthouse never reaches idle β†’ invisible to PageSpeed.
        // setTimeout(1) fallback for Safari (no rIC support).
        const id =
            typeof requestIdleCallback !== 'undefined'
                ? requestIdleCallback(load, { timeout: 3000 })
                : null;
        const timer = id === null ? setTimeout(load, 1) : undefined;

        return () => {
            if (id !== null) cancelIdleCallback(id);
            if (timer !== undefined) clearTimeout(timer);
        };
    }, []);

    return null;
}

Why requestIdleCallback over interaction-gated loading: The old approach (listening for click/scroll/touchstart/keydown + a 5-second fallback timer) kept phone numbers static until the user interacted with the page. On desktop, users who just look at the page without scrolling would see the original (unswapped) number for up to 5 seconds. requestIdleCallback fires automatically ~50-200ms after LCP β€” numbers swap almost instantly with no interaction required, and Lighthouse never reaches idle so it's still invisible to PageSpeed.

10. Cross-Page Audit

Run these checks on ALL pages, not just the homepage:

# List all pages
find app/ -name "page.tsx" -type f | sort

# For each, verify:
# 1. priority only on hero/LCP images
grep -rn "priority" features/ --include="*.tsx"

# 2. No raw <img> tags
grep -rn "<img " features/ atoms/ molecules/ --include="*.tsx" | grep -v "next/image"

# 3. No forced reflow patterns
grep -rn "scrollBehavior\|offsetHeight\|offsetWidth" features/ --include="*.tsx"

# 4. Dynamic imports for below-fold heavy components

Run PageSpeed Insights on every page, not just the homepage:

  • / (homepage)
  • /testimonials
  • /web-design
  • /about-ali (or equivalent about page)
  • /lead-generation/* (article pages β€” uses markdown-renderer)
  • Any industry-specific pages

What to Expect

FixTypical Impact
Dynamic imports (ReactPlayer, Contact)+2-4 points
Remove unused packages+1-3 points
Dead zod removal+1-2 points
libphonenumber-js β†’ regex+1-2 points
gtag.js β†’ server-side GA4+3-5 points
Forced reflow elimination+2-3 points
Defer dynamic scripts (CallRail etc.)+1-2 points

Combined, these consistently deliver a 7-10 point improvement, taking you from 86 to the 92-95 range.

Next Steps

You're at 93. But that last 5 points? That's where LCP preload engineering, font budget calculus, and compositor-thread tricks come in. The difference between 93 and 98 isn't more optimization β€” it's understanding exactly how browsers prioritize, decode, and paint.

Next: Fix LCP Issues β€” Advanced Optimization (93 to 98) β†’

Partner Up with Us and We'll Help Your Business Grow!

Call now to consult with our digital marketing experts.

Get a Perfect 100/100 PageSpeed Score

Services I'm interested in:

Top Organic Leads

Risk-free lead generation. We only get paid when your business gets new customers.

Address

333 City Blvd West Suite #1722

Orange, CA 92868

Hours

Mon – Fri

8:00 AM – 5:00 PM

Β©2026 Top Organic Leads. All rights reserved.