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.18That'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: ~800msSolution: 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.4MBSolution: 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 fontsSolution: 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/100 ✅
First 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 strategyTools 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