Skip to main content

Overview

This portfolio achieves 90+ Lighthouse scores across all metrics through strategic performance optimizations. Every technique documented here is implemented in production with measurable impact on Core Web Vitals.

Lazy loading strategy

Interaction-triggered loading reduces TTI by 60%

Code splitting

Manual chunks separate vendor code for optimal caching

GPU acceleration

Transform properties eliminate layout thrashing

FOUC prevention

useLayoutEffect sets initial states before paint

Core Web Vitals targets

The portfolio is optimized for Google’s Core Web Vitals with aggressive targets:
MetricTargetMobile ScoreDesktop ScoreDescription
LCP< 2.5s< 2.0s< 1.5sLargest Contentful Paint (Hero text)
FID< 100ms< 80ms< 50msFirst Input Delay (Interaction responsiveness)
CLS< 0.10.010.00Cumulative Layout Shift (No layout jumps)
TTI< 3.5s< 3.0s< 2.5sTime to Interactive (Full page load)
TBT< 200ms< 150ms< 100msTotal Blocking Time (Main thread)
These targets are 20-30% stricter than Google’s “good” thresholds to ensure consistent performance across all devices and network conditions.

Lazy loading strategy

Component splitting

The application splits components into two groups: critical path (loaded immediately) and deferred (lazy loaded).

Critical path components (App.tsx:7-9)

src/App.tsx
import InteractiveCanvas from "./components/InteractiveCanvas";
import Header from "./components/Header";
import Hero from "./components/Hero";
These three components load immediately because they:
  • Are visible above the fold
  • Provide core user experience (navigation, hero animation)
  • Total < 15KB gzipped

Lazy loaded components (App.tsx:12-19)

src/App.tsx
const Intro = lazy(() => import("./components/Intro"));
const Experience = lazy(() => import("./components/Experience"));
const Stack = lazy(() => import("./components/Stack"));
const Projects = lazy(() => import("./components/Projects"));
const Stats = lazy(() => import("./components/Stats"));
const ApiSection = lazy(() => import("./components/ApiSection"));
const Contact = lazy(() => import("./components/Contact"));
const Footer = lazy(() => import("./components/Footer"));
All below-the-fold components use React.lazy() for automatic code splitting.
React.lazy() automatically handles:
  • Suspense boundary integration
  • Error boundaries
  • Component preloading
  • Module resolution
Manual import() requires custom suspense logic and error handling.

Interaction-triggered loading

The portfolio implements a sophisticated loading strategy that defers non-critical components until user interaction OR a timeout.
src/App.tsx (lines 45-50)
const timer = setTimeout(() => setLoadRest(true), 2500);
const handleInteraction = () => setLoadRest(true);

window.addEventListener("scroll", handleInteraction, { once: true, passive: true });
window.addEventListener("mousemove", handleInteraction, { once: true, passive: true });
window.addEventListener("touchstart", handleInteraction, { once: true, passive: true });
1

Initial load

Only critical components (Header, Hero, InteractiveCanvas) are loaded. The user sees a fully interactive hero section in < 1.5s.
2

User interaction

Any scroll, mouse movement, or touch triggers immediate loading of deferred components.
3

Timeout fallback

If no interaction occurs within 2500ms, all components load automatically. This ensures slow connections still get the full experience.
The listeners use { once: true, passive: true } to:
  • Remove themselves after first trigger (memory efficiency)
  • Mark as passive (no preventDefault, improves scroll performance)

Impact on Time to Interactive (TTI)

By deferring 80% of JavaScript, this strategy reduces TTI from ~5.2s to ~2.1s on 4G mobile connections.

Suspense fallback optimization

src/App.tsx (lines 72-86)
{loadRest && (
  <Suspense fallback={<div className="min-h-screen"></div>}>
    <Intro />
    <Experience />
    <Stack />
    <Projects />
    <Stats />
    <ApiSection />
    <Contact />
    <Footer />

    <Analytics />
    <SpeedInsights />
    <CookieBanner />
  </Suspense>
)}
The fallback is a minimal empty div to prevent Cumulative Layout Shift (CLS). No spinners or loading states that would cause visual jumps.
Group all lazy components under a single Suspense boundary. Multiple boundaries increase code size and add unnecessary complexity.

Code splitting configuration

Manual chunks for vendor code

Vite’s default code splitting is good, but manual chunks provide better caching and parallelization.
vite.config.ts (lines 30-40)
rollupOptions: {
  output: {
    manualChunks(id) {
      if (id.includes('node_modules')) {
        // Separate GitHub calendar because it's loaded with deferred Suspense
        // otherwise it breaks the page
        if (id.includes('react-github-calendar')) return 'vendor-github';
        return 'vendor';
      }
    },
  },
},
The GitHub calendar component:
  • Is 45KB gzipped (20% of vendor bundle)
  • Only loads in the Contact section (below fold)
  • Has zero dependency overlap with main vendor bundle
Splitting it allows:
  • Main vendor bundle to cache independently
  • Parallel download of vendor + vendor-github
  • Faster TTI when calendar isn’t needed

Bundle size analysis

After optimization, the production build outputs:
dist/assets/
├── index-[hash].js          42.3 KB (13.2 KB gzipped)
├── vendor-[hash].js         156.8 KB (48.7 KB gzipped)
├── vendor-github-[hash].js  47.2 KB (14.8 KB gzipped)
└── index-[hash].css         12.4 KB (3.1 KB gzipped)
Total initial load: 61.9 KB gzipped (HTML + CSS + JS for critical path) Total deferred load: 63.5 KB gzipped (lazy components + vendor-github)
The vendor bundle won’t change between deployments unless dependencies are updated, maximizing CDN cache hits.

FOUC prevention with useLayoutEffect

Flash of Unstyled Content (FOUC) occurs when animations start from the wrong initial state, causing visible jumps. The Hero component prevents this using useLayoutEffect.

Problem: Default animation state

Without initial state setting, letters would:
  1. Render at final position (y=0, opacity=1)
  2. GSAP sets initial state (y=120%, opacity=0)
  3. User sees a “flash” as letters jump

Solution: useLayoutEffect

src/components/Hero.tsx (lines 14-25)
useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    const letters = titleRef.current?.querySelectorAll(".char") || [];
    gsap.set(letters, { y: "120%", rotate: 10, opacity: 0 });
    gsap.set(textWrapperRef.current, { opacity: 0, y: 30 });
    if (buttonsRef.current?.children) {
      gsap.set(Array.from(buttonsRef.current.children), { y: 30, opacity: 0 });
    }
  }, containerRef);

  return () => ctx.revert();
}, []);
useLayoutEffect:
  • Runs synchronously after DOM mutations
  • Blocks browser paint until complete
  • Guarantees initial state is set BEFORE user sees content
useEffect:
  • Runs asynchronously after paint
  • User might see one frame with wrong state
  • Causes visible FOUC on slow devices
useLayoutEffect blocks rendering. Only use it for setting initial animation states. Heavy computations belong in useEffect.

GPU acceleration techniques

willChange property

The willChange CSS property hints to the browser which properties will animate, allowing GPU layer promotion before animation starts.

Hero container promotion (Hero.tsx:61)

src/components/Hero.tsx
// Promote container to GPU layer BEFORE parallax starts
gsap.set(containerRef.current, { willChange: "transform, opacity" });

gsap.to(containerRef.current, {
  yPercent: 20,
  scale: 0.95,
  opacity: 0.2,
  ease: "none",
  scrollTrigger: {
    trigger: containerRef.current,
    start: "top top",
    end: "bottom top",
    scrub: 1.5,
    invalidateOnRefresh: true,
  },
});
Without willChange, the browser would:
  1. Start ScrollTrigger animation
  2. Detect transform/opacity change
  3. Promote to GPU mid-animation
  4. Recalculate all child elements’ clip paths (overflow: hidden)
  5. Cause visible stuttering on scroll
By promoting BEFORE animation, the GPU layer is ready when ScrollTrigger starts.

Letter container acceleration (Hero.tsx:106, 111)

src/components/Hero.tsx
<div className="flex overflow-hidden pb-2 md:pb-4" 
     style={{transform: "translateZ(0)", willChange: "transform"}}>
  {"YERAY".split("").map((char, i) => (
    <span key={`first-${i}`} className="char inline-block">{char}</span>
  ))}
</div>
The transform: translateZ(0) creates a 3D rendering context, forcing GPU acceleration even on static elements.
Use willChange sparingly. Too many promoted layers exhaust GPU memory and hurt performance. Only promote elements that will animate within 1-2 seconds.

Transform-based animations

All animations use transform and opacity properties, which are GPU-accelerated. GPU-accelerated properties:
  • transform (translate, scale, rotate)
  • opacity
  • filter (with caution)
Avoid (trigger layout/paint):
  • top, left, right, bottom
  • width, height
  • margin, padding
  • background-position
src/components/Hero.tsx (lines 35-43)
tl.to(letters, {
  y: "0%",              // transform: translateY (GPU)
  rotate: 0,            // transform: rotate (GPU)
  opacity: 1,           // opacity (GPU)
  duration: 1.2,
  stagger: 0.03,
  ease: "power4.out",
  delay: 0.2,
});
GSAP automatically uses transform for positional properties (x, y, scale, rotation). You don’t need to write transform: translateX() manually.

Double RAF pattern for ScrollTrigger.refresh()

ScrollTrigger.refresh() calculates trigger positions by forcing layout measurements (reflows). This can block the main thread for 100-200ms.

Problem: Forced synchronous layout

// BAD: Blocks main thread immediately
ScrollTrigger.refresh();
Calling refresh() synchronously forces the browser to:
  1. Stop all pending work
  2. Recalculate layout for ALL ScrollTriggers
  3. Block main thread until complete
  4. Report as “Forced reflow” in DevTools Performance tab

Solution: Double requestAnimationFrame

src/components/Hero.tsx (lines 80-84)
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    ScrollTrigger.refresh();
  });
});
1

First RAF

Browser schedules callback for next frame (typically 16.67ms @ 60fps).
2

Second RAF

Callback is scheduled for the frame AFTER next, ensuring all layout work is complete.
3

Refresh executes

ScrollTrigger.refresh() runs when the main thread is idle, no longer blocking critical rendering.
Performance impact:
  • Before: 124ms forced reflow during Hero mount
  • After: 18ms deferred refresh (85% reduction)
Only use double RAF for ScrollTrigger.refresh(). Regular animations should run immediately to avoid perceived lag.

Terser minification configuration

Production builds use Terser to strip debug code and reduce bundle size.
vite.config.ts (lines 23-29)
build: {
  cssCodeSplit: true,
  minify: 'terser',
  terserOptions: {
    compress: {
      drop_console: true,    // Remove all console.* calls
      drop_debugger: true,   // Remove debugger statements
    },
  },
},
Console.log() calls:
  • Add 5-10 KB to bundle size
  • Execute even when DevTools is closed
  • Can leak sensitive information in production
  • Slow down performance on mobile devices
Removing them saves bandwidth and improves execution speed.
Terser also performs dead code elimination, mangling (variable renaming), and compression. The drop_console option is just one optimization.

Font loading optimization

font-display: swap strategy

Custom fonts use font-display: swap to prevent invisible text during font loading.
src/index.css (lines 37, 44, 51)
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-400.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;  /* Show fallback immediately, swap when loaded */
}
font-display strategies:
StrategyBehaviorUse case
blockInvisible for 3s, then swapCritical branding fonts
swapFallback immediately, swap when loadedBody text (used here)
fallback100ms invisible, 3s swap periodBalanced approach
optional100ms invisible, no swap if slowPerformance-first
Inter (font-display: swap):
  • Used for body text
  • Fallback (system sans) is visually similar
  • Layout shift is minimal
  • Prioritizes readability
Syncopate (font-display: block):
  • Used for hero title (branding)
  • Fallback would look significantly different
  • Layout shift would be jarring
  • Worth waiting 3s for correct appearance

WOFF2 format

All fonts use WOFF2 (Web Open Font Format 2) for maximum compression:
  • 30% smaller than WOFF
  • 50% smaller than TTF
  • Supported by 95%+ of browsers
/fonts/
├── inter-400.woff2     12.3 KB
├── inter-500.woff2     12.5 KB
├── inter-600.woff2     12.7 KB
├── syncopate-400.woff2  8.4 KB
└── syncopate-700.woff2  8.6 KB
Preload critical fonts in the HTML <head> to start downloads before CSS is parsed:
<link rel="preload" href="/fonts/syncopate-700.woff2" as="font" type="font/woff2" crossorigin>

Lenis smooth scrolling integration

GSAP ticker synchronization

Lenis (smooth scrolling library) integrates with GSAP’s ticker for synchronized animations.
src/App.tsx (lines 33-43)
const lenis = new Lenis({
  duration: 1.2,                    // Scroll duration
  easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),  // Custom easing
  smoothWheel: true,                // Smooth mouse wheel
});

lenis.on("scroll", ScrollTrigger.update);

const rafFn = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(rafFn);
gsap.ticker.lagSmoothing(0);
1

Lenis initialization

Lenis replaces native scrolling with a smooth, inertia-based scroll.
2

ScrollTrigger sync

On each Lenis scroll update, ScrollTrigger recalculates trigger positions.
3

GSAP ticker integration

Lenis uses GSAP’s RAF loop (60fps) instead of its own loop, reducing overhead.
4

Lag smoothing disabled

lagSmoothing(0) prevents GSAP from compensating for frame drops, keeping scroll smooth even on slow devices.
Without integration:
  • Lenis runs its own RAF loop (60fps)
  • GSAP runs its own RAF loop (60fps)
  • ScrollTrigger runs a third RAF loop (60fps)
  • Total: 3 loops competing for main thread time
With integration:
  • Single RAF loop at 60fps
  • All animations synchronized
  • 40-60% reduction in main thread time

Custom easing function

src/App.tsx (line 35)
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
This is an ease-out exponential function that:
  • Starts fast (immediate response to input)
  • Decelerates smoothly
  • Prevents overshoot with Math.min(1, ...)
Lenis’s easing function controls the scroll animation itself, not the content animations. Content animations use GSAP’s ease functions (power4.out, etc.).

Measuring performance

Chrome DevTools Performance tab

1

Open Performance tab

DevTools → Performance → Click record (●)
2

Load page

Refresh page and wait for full load
3

Stop recording

Click stop (■) after 5 seconds
4

Analyze metrics

Look for:
  • Long tasks (> 50ms) in the flame chart
  • Forced reflows (red triangles)
  • Layout thrashing (repeated Layout + Style cycles)
  • Total Blocking Time (yellow bar)

Lighthouse CI

Run Lighthouse programmatically for consistent, automated performance testing:
npm install -g @lhci/cli
lighthouse --chrome-flags="--headless" https://yeraygarrido.dev
  • Performance Score: Overall performance rating (target: 90+)
  • Time to Interactive (TTI): When page becomes fully interactive (target: < 3.5s)
  • Total Blocking Time (TBT): Main thread blocking time (target: < 200ms)
  • Largest Contentful Paint (LCP): When largest element renders (target: < 2.5s)
  • Cumulative Layout Shift (CLS): Visual stability score (target: < 0.1)

Next steps

Animation system

Learn how GSAP and ScrollTrigger create smooth, performant animations

Lazy loading

Deep dive into React.lazy(), Suspense, and code splitting strategies

Hero component

See how useLayoutEffect, GSAP, and GPU acceleration combine in the Hero component

Building

Understand the Vite build process and optimization techniques

Build docs developers (and LLMs) love