^.^;
Back to Blog
Blog Post

Mobile Performance: From 3.1s to 1.5s Load Time (Lighthouse 98/100)

Complete mobile performance optimization journey with real metrics. CSS optimization, font self-hosting, code splitting, and analytics deferral that doubled performance.

J
JG
Author
2025-11-18
Published
◆ ◆ ◆

Mobile Performance: From 3.1s to 1.5s Load Time

My Lighthouse mobile score was embarrassing: 68/100. Load time: 3.1 seconds. On a portfolio site.

This is the complete optimization story that got it to 98/100 with 1.5s load time.

The Baseline (The Bad)

Lighthouse Mobile Audit (Before):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Performance:        68/100 🔴
First Contentful Paint:  2.4s
Time to Interactive:      3.1s
Total Blocking Time:    580ms
Largest Contentful Paint: 3.2s
Cumulative Layout Shift:  0.18

That's... not good. Let's fix it.

Phase 1: CSS Optimization

Problem: Bloated CSS

# Initial CSS bundle
main.css: 320KB (uncompressed)

Most of it was unused Tailwind classes.

Solution: PurgeCSS + Production Config

// tailwind.config.js
module.exports = {
  content: [
    './src/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

Result: CSS bundle 320KB → 42KB (-87%)

Time saved: ~350ms on mobile 3G

Phase 2: Font Self-Hosting

Problem: External Font Requests

1. DNS lookup to fonts.googleapis.com
2. Download font CSS
3. Parse CSS
4. Download font files (woff2)
Total: ~800ms

Solution: Self-Host with `next/font`

// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // 👈 Prevents FOIT
  preload: true,
  variable: '--font-inter',
});

const firaCode = Fira_Code({
  subsets: ['latin'],
  display: 'swap',
  weight: ['400', '500', '700'],
  variable: '--font-fira-code',
});

export default function RootLayout({ children }: { children: React.NodeNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
      <body>{children}</body>
    </html>
  );
}
👶

Explain Like I'm 3

Imagine you need to borrow a toy from your friend's house across the street. You have to walk there, knock on the door, wait for them to find the toy, then walk back. That takes FOREVER! But what if you already have that toy at YOUR house? You can just grab it instantly! That's font self-hosting - instead of asking Google for fonts (walking across the street), we keep them in our own house (our website) so they load super fast!

💼

Explain Like You're My Boss

External font requests to Google Fonts incur DNS lookup, connection establishment, SSL handshake, and download latency - typically 600-800ms on mobile 3G. next/font automatically downloads, optimizes, and self-hosts fonts at build time, eliminating external requests. The display: 'swap' strategy prevents FOIT (Flash of Invisible Text), showing fallback fonts immediately while custom fonts load.

Performance Impact: 800ms reduction in font loading time = 25% faster Time to Interactive. Improved mobile UX translates to measurable bounce rate reduction.

💕

Explain Like You're My Girlfriend

You know how I used to drive to Starbucks every morning for coffee? 15 minutes there, 5 minutes wait, 15 minutes back = 35 minutes wasted? Then you convinced me to just make coffee at home and now it takes 2 minutes? That's literally what we did here. Instead of asking Google "hey can you send me these fonts?" (which takes 800ms because internet is slow), we just keep the fonts right here on our server. Instant coffee = instant fonts = happy users who don't bounce! Plus I saved like $150/month on Starbucks. Win-win! ☕💕

Result: Font loading 800ms → 50ms (-94%)

Why it works:

  • Fonts bundled with app
  • No external DNS lookup
  • Served from same origin
  • Cached aggress

ively

Phase 3: Code Splitting

Problem: Massive Initial Bundle

# Initial JavaScript
_app.js: 892KB (uncompressed)

Every page loaded everything, even stuff not needed.

Solution: Dynamic Imports

// Before
import { CybersecurityLab } from '@/components/CybersecurityLab';
import { PsychologyLab } from '@/components/PsychologyLab';
import { AnimatedBackground } from '@/components/AnimatedBackground';

// After
const CybersecurityLab = dynamic(() => import('@/components/CybersecurityLab'), {
  loading: () => <LoadingSpinner />,
  ssr: false, // Client-side only
});

const PsychologyLab = dynamic(() => import('@/components/PsychologyLab'), {
  loading: () => <LoadingSpinner />,
});

const AnimatedBackground = dynamic(() => import('@/components/AnimatedBackground'), {
  loading: () => null,
  ssr: false,
});

Result: Initial bundle 892KB → 180KB (-80%)

Time saved: ~1.2s on mobile

Phase 4: Analytics Deferral

Problem: Analytics Blocking Render

// Before (in <head>)
<Script src="https://analytics.example.com/script.js" />

Analytics loaded before content. Users saw blank screen.

Solution: Defer Until After Interactive

// After
<Script
  src="https://analytics.example.com/script.js"
  strategy="lazyOnload" // 👈 Load after everything else
/>

Result: Time to Interactive 3.1s → 2.1s (-32%)

Phase 5: Image Optimization

Problem: Massive Images

hero-bg.jpg: 2.4MB
profile.jpg: 1.8MB
lab-screenshot.png: 3.2MB
Total: 7.4MB

Solution: Next.js Image + WebP/AVIF

// Before
<img src="/hero-bg.jpg" alt="Background" />

// After
<Image
  src="/hero-bg.jpg"
  alt="Background"
  fill
  priority // Above fold
  quality={85} // Balance quality/size
  sizes="100vw"
  className="object-cover"
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQ..." // Low-quality placeholder
/>

Result: Images 7.4MB → 420KB (-94%)

Time saved: ~2.8s on mobile 3G

Phase 6: Lazy Loading Below Fold

Problem: Loading Everything Upfront

// Homepage loading 15 components immediately
<LabSection />
<ProjectSection />
<BlogSection />
<TestimonialsSection />
// ...

Solution: Intersection Observer + Lazy Load

'use client';
import { useInView } from 'react-intersection-observer';

export function LazySection({ children }: { children: React.ReactNode }) {
  const { ref, inView } = useInView({
    triggerOnce: true,
    rootMargin: '200px 0px', // Load 200px before visible
  });

  return (
    <div ref={ref}>
      {inView ? children : <div style={{ minHeight: '400px' }} />}
    </div>
  );
}

// Usage
<LazySection>
  <LabSection />
</LazySection>
<LazySection>
  <ProjectSection />
</LazySection>

Result: Initial render 600ms → 280ms (-53%)

Phase 7: Preloading Critical Assets

Problem: Waterfall Loading

1. HTML loads
2. Parses HTML
3. Discovers CSS
4. Downloads CSS
5. Discovers fonts
6. Downloads fonts

Solution: Preload in `<head>`

// app/layout.tsx
<head>
  <link
    rel="preload"
    href="/fonts/inter-var.woff2"
    as="font"
    type="font/woff2"
    crossOrigin="anonymous"
  />
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="dns-prefetch" href="https://fonts.googleapis.com" />
  <link rel="preload" href="/hero-bg.jpg" as="image" />
</head>

Result: Critical assets load parallel, not waterfall

Time saved: ~400ms

Phase 8: Remove Render-Blocking Resources

Problem: Blocking CSS/JS

<!-- Before -->
<link rel="stylesheet" href="/styles/global.css" />
<script src="/scripts/analytics.js"></script>

Solution: Inline Critical CSS

// app/layout.tsx
<head>
  <style dangerouslySetInnerHTML={{
    __html: `
      /* Critical CSS inline (first paint) */
      body { margin: 0; font-family: var(--font-inter); }
      .hero { min-height: 100vh; }
      /* ... */
    `
  }} />
  <link rel="stylesheet" href="/styles/global.css" media="print" onLoad="this.media='all'" />
</head>

Result: First Contentful Paint 2.4s → 0.8s (-67%)

The Final Results

Lighthouse Mobile Audit (After):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Performance:        98/100First Contentful Paint:  0.8s ✅
Time to Interactive:      1.5s ✅
Total Blocking Time:     80ms ✅
Largest Contentful Paint: 1.2s ✅
Cumulative Layout Shift:  0.01

| Metric | Before | After | Improvement |

|--------|--------|-------|-------------|

| Performance Score | 68 | 98 | +44% |

| FCP | 2.4s | 0.8s | -67% |

| TTI | 3.1s | 1.5s | -52% |

| TBT | 580ms | 80ms | -86% |

| LCP | 3.2s | 1.2s | -62% |

| CLS | 0.18 | 0.01 | -94% |

The Complete Optimization Checklist

✅ CSS: Purge unused Tailwind classes
✅ Fonts: Self-host with next/font
✅ JavaScript: Dynamic imports for heavy components
✅ Analytics: Defer until after interactive
✅ Images: Next.js Image + WebP/AVIF
✅ Lazy loading: Below-fold content
✅ Preloading: Critical assets
✅ CSS: Inline critical above-fold styles
✅ Bundle: Code splitting by route
✅ Caching: Aggressive service worker strategy

Tools I Used

  • Lighthouse CI: Automated testing
  • WebPageTest: Real-world 3G testing
  • Chrome DevTools: Performance profiler
  • Bundle Analyzer: Find large dependencies
  • Squoosh: Image optimization

Key Lessons

  • Measure first: Don't optimize blindly
  • Low-hanging fruit first: CSS/fonts are easy wins
  • Test on real devices: Emulator isn't accurate
  • Monitor continuously: Performance degrades over time
  • User experience > perfect score: 98 is fine, 100 isn't worth the tradeoff

The Bottom Line

Going from 68 to 98 took 8 phases over 3 days. But it was worth it:

  • 52% faster time to interactive
  • 67% faster first contentful paint
  • Users notice the difference

Performance isn't vanity metrics. It's user experience.


Want the complete code? Check out my [GitHub repo](https://github.com/Daelyte/jg_portfolio_website) or ask me anything about mobile optimization!


James G. - AI Alchemist | Full-Stack Developer

END OF ARTICLE
J

About JG

Full-stack developer specializing in web performance, authentication systems, and developer experience. Passionate about sharing real-world debugging stories and optimization techniques.

Terms of ServiceLicense AgreementPrivacy Policy
Copyright © 2025 JMFG. All rights reserved.