Auditoría de Performance

👨‍🍳 Chef⏱️ 60 minutos

📋 Prerequisitos sugeridos

  • Una aplicacion web funcionando
  • Chrome DevTools basico

Auditoria de Performance: De Lento a Veloz

En este taller practico vas a tomar una aplicacion lenta y la vas a optimizar paso a paso. No es teoria: vas a medir, identificar problemas, y ver mejoras reales en los numeros.

Al terminar, tendras un checklist que podras usar en cualquier proyecto futuro.


La Aplicacion Problema

Primero, vamos a crear una aplicacion con todos los problemas de performance tipicos. Asi aprenderas a identificarlos y arreglarlos.

Estructura del Proyecto

slow-app/
├── package.json
├── src/
│   ├── App.tsx
│   ├── components/
│   │   ├── HeavyComponent.tsx
│   │   ├── ProductList.tsx
│   │   ├── ImageGallery.tsx
│   │   └── SearchBox.tsx
│   ├── utils/
│   │   └── slowOperations.ts
│   └── index.tsx
└── public/
    └── images/
        └── (imagenes grandes sin optimizar)

package.json

{
  "name": "slow-app",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "lodash": "^4.17.21",
    "moment": "^2.29.4",
    "axios": "^1.6.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "typescript": "^5.0.0",
    "vite": "^5.0.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

App.tsx - El Componente Principal (con problemas)

// ANTES: App con multiples problemas de performance
import React, { useState, useEffect } from 'react'
import moment from 'moment' // Libreria pesada completa
import _ from 'lodash' // Todo lodash importado
import HeavyComponent from './components/HeavyComponent'
import ProductList from './components/ProductList'
import ImageGallery from './components/ImageGallery'
import SearchBox from './components/SearchBox'

function App() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)
  const [searchTerm, setSearchTerm] = useState('')

  // Problema 1: Fetch sin cache
  useEffect(() => {
    fetch('https://fakestoreapi.com/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data)
        setLoading(false)
      })
  }, [])

  // Problema 2: Filtrado en cada render
  const filteredProducts = products.filter(p =>
    p.title.toLowerCase().includes(searchTerm.toLowerCase())
  )

  // Problema 3: Formateo de fecha ineficiente
  const formatDate = (date) => {
    return moment(date).format('MMMM Do YYYY, h:mm:ss a')
  }

  // Problema 4: Calculo pesado sin memoizacion
  const expensiveCalculation = () => {
    let result = 0
    for (let i = 0; i < 10000000; i++) {
      result += Math.sqrt(i) * Math.random()
    }
    return result
  }

  const calculatedValue = expensiveCalculation()

  return (
    <div className="app">
      <header>
        <h1>Tienda Demo</h1>
        <p>Actualizado: {formatDate(new Date())}</p>
        <p>Valor calculado: {calculatedValue.toFixed(2)}</p>
      </header>

      <SearchBox
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      <HeavyComponent />

      <ImageGallery />

      {loading ? (
        <p>Cargando...</p>
      ) : (
        <ProductList products={filteredProducts} />
      )}
    </div>
  )
}

export default App

HeavyComponent.tsx - Componente Pesado

// ANTES: Componente que carga sincronicamente
import React from 'react'
// Importaciones pesadas innecesarias
import Chart from 'chart.js/auto' // 200KB+
import * as THREE from 'three' // 500KB+

function HeavyComponent() {
  // Inicializacion pesada en cada render
  const initializeChart = () => {
    // Configuracion compleja...
    return null
  }

  return (
    <div className="heavy-component">
      <h2>Componente con Graficos</h2>
      <div id="chart-container"></div>
    </div>
  )
}

export default HeavyComponent

ProductList.tsx - Lista Sin Virtualizacion

// ANTES: Renderiza todos los items a la vez
import React from 'react'

interface Product {
  id: number
  title: string
  price: number
  image: string
  description: string
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <div className="product-list">
      {products.map(product => (
        <div key={product.id} className="product-card">
          {/* Imagen sin optimizar */}
          <img
            src={product.image}
            alt={product.title}
            style={{ width: '200px', height: '200px' }}
          />
          <h3>{product.title}</h3>
          <p>{product.description}</p>
          <p className="price">${product.price}</p>
        </div>
      ))}
    </div>
  )
}

export default ProductList

SearchBox.tsx - Busqueda Sin Debounce

// ANTES: Cada keystroke dispara re-render
import React from 'react'

function SearchBox({ value, onChange }) {
  return (
    <div className="search-box">
      <input
        type="text"
        placeholder="Buscar productos..."
        value={value}
        onChange={onChange}
      />
    </div>
  )
}

export default SearchBox

Paso 1: Auditoria Inicial con Lighthouse

Ejecutar Lighthouse

  1. Abre Chrome DevTools (F12)
  2. Ve a la pestana Lighthouse
  3. Selecciona:
    • Mode: Navigation
    • Device: Mobile
    • Categories: Performance, Best Practices, SEO
  4. Click en Analyze page load

Interpretar Resultados

RESULTADOS INICIALES (ejemplo):
================================
Performance Score: 35/100

Core Web Vitals:
- LCP (Largest Contentful Paint): 4.2s (MALO - debe ser < 2.5s)
- FID (First Input Delay): 320ms (MALO - debe ser < 100ms)
- CLS (Cumulative Layout Shift): 0.25 (MEJORABLE - debe ser < 0.1)

Metricas adicionales:
- First Contentful Paint: 2.1s
- Speed Index: 4.8s
- Time to Interactive: 6.3s
- Total Blocking Time: 890ms

Oportunidades identificadas:
- Reduce unused JavaScript: 450KB potential savings
- Properly size images: 320KB potential savings
- Serve images in modern formats: 180KB potential savings
- Eliminate render-blocking resources: 1.2s potential savings

Diagnosticos Clave

ProblemaCausaImpacto
Alto TBTCalculo expensivo bloqueando main threadFID malo
Alto LCPImagenes grandes, JS bloqueanteUX lenta
Alto CLSImagenes sin dimensionesSaltos visuales

Paso 2: Performance Tab de DevTools

Grabar una Sesion

  1. Abre DevTools > Performance
  2. Click en el boton de grabar (circulo)
  3. Interactua con la app (scroll, buscar, etc.)
  4. Click en stop
  5. Analiza el flamegraph

Que Buscar

Timeline de problemas:
======================

[====SCRIPTING (amarillo)====]  <- JavaScript ejecutando
     [===RENDERING (morado)===] <- Layout y paint
          [=PAINTING (verde)=]  <- Pintando pixeles

Problemas comunes:
- Long Tasks (barras rojas): Tareas > 50ms que bloquean
- Forced Reflow: Layout sincronico forzado
- Excessive Painting: Repintado innecesario

Identificar Long Tasks

Busca barras rojas en la linea "Main". Cada una es una tarea que bloquea el thread principal por mas de 50ms.

En nuestra app problematica, veras:

  1. expensiveCalculation() - 200ms+ de bloqueo
  2. Moment.js parsing - 50ms+ en cada render
  3. Product filtering - Crece con mas productos

Paso 3: Network Waterfall

Analizar Carga de Recursos

  1. DevTools > Network
  2. Recarga la pagina (Ctrl+Shift+R para cache limpio)
  3. Observa el waterfall
Waterfall problematico:
========================

|--document.html (100ms)
   |--bundle.js (800KB, 2s) <-- BLOQUEA TODO
      |--chunk-lodash.js (70KB)
      |--chunk-moment.js (200KB)
      |--chunk-three.js (500KB)
         |--products-api (200ms)
            |--image1.jpg (500KB, 1s)
            |--image2.jpg (450KB, 0.9s)
            |--image3.jpg (600KB, 1.2s)

Tiempo total: ~5.3s

Problemas Identificados

  1. Bundle enorme: 800KB de JS en un solo archivo
  2. Carga secuencial: Imagenes esperan al JS
  3. Sin compresion: Imagenes en formato original
  4. Sin cache headers: Cada visita descarga todo

Paso 4: Arreglar Imports Pesados

Antes: Importar Todo

// MALO: Importa toda la libreria
import moment from 'moment' // 300KB
import _ from 'lodash' // 70KB

Despues: Imports Selectivos

// BUENO: Solo lo que necesitas
import { format } from 'date-fns' // 2KB vs 300KB
import debounce from 'lodash/debounce' // 1KB vs 70KB

// O mejor aun, funciones nativas:
const formatDate = (date: Date) => {
  return new Intl.DateTimeFormat('es', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  }).format(date)
}

Impacto

Ahorro en bundle:
- moment.js: -300KB
- lodash completo: -70KB
- Total: -370KB (46% del bundle original)

Paso 5: Lazy Loading de Componentes

Antes: Todo Carga al Inicio

// MALO: HeavyComponent carga aunque no se vea
import HeavyComponent from './components/HeavyComponent'

function App() {
  return (
    <div>
      <Hero />
      <HeavyComponent /> {/* Carga 500KB aunque usuario no scrollee */}
    </div>
  )
}

Despues: Lazy Loading con Suspense

// BUENO: Carga solo cuando se necesita
import React, { lazy, Suspense } from 'react'

const HeavyComponent = lazy(() => import('./components/HeavyComponent'))

function App() {
  return (
    <div>
      <Hero />
      <Suspense fallback={<div className="skeleton">Cargando graficos...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  )
}

Con Intersection Observer (cargar al scroll)

import React, { lazy, Suspense, useState, useEffect, useRef } from 'react'

const HeavyComponent = lazy(() => import('./components/HeavyComponent'))

function LazySection() {
  const [isVisible, setIsVisible] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true)
          observer.disconnect()
        }
      },
      { rootMargin: '100px' } // Carga 100px antes de ser visible
    )

    if (ref.current) {
      observer.observe(ref.current)
    }

    return () => observer.disconnect()
  }, [])

  return (
    <div ref={ref}>
      {isVisible ? (
        <Suspense fallback={<div className="skeleton">Cargando...</div>}>
          <HeavyComponent />
        </Suspense>
      ) : (
        <div className="placeholder" style={{ height: '400px' }} />
      )}
    </div>
  )
}

Paso 6: Optimizacion de Imagenes

Antes: Imagenes Sin Optimizar

// MALO: Imagen original de 2MB
<img
  src="/images/hero.jpg"
  alt="Hero"
/>

Despues: Con next/image (Next.js)

import Image from 'next/image'

// BUENO: Optimizacion automatica
<Image
  src="/images/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // Para imagenes above-the-fold
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>

Sin Next.js: Optimizacion Manual

// Componente de imagen optimizada
function OptimizedImage({
  src,
  alt,
  width,
  height
}: {
  src: string
  alt: string
  width: number
  height: number
}) {
  const webpSrc = src.replace(/\.(jpg|png)$/, '.webp')

  return (
    <picture>
      {/* WebP para navegadores modernos */}
      <source srcSet={webpSrc} type="image/webp" />
      {/* Fallback para navegadores viejos */}
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        decoding="async"
        style={{
          aspectRatio: `${width}/${height}`,
          objectFit: 'cover'
        }}
      />
    </picture>
  )
}

Responsive Images

<img
  src="/images/product-400.jpg"
  srcSet="
    /images/product-400.jpg 400w,
    /images/product-800.jpg 800w,
    /images/product-1200.jpg 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
  alt="Producto"
  loading="lazy"
  width={800}
  height={600}
/>

Script para Convertir Imagenes

# Instalar sharp-cli
npm install -g sharp-cli

# Convertir a WebP con calidad 80%
sharp -i input.jpg -o output.webp -f webp -q 80

# Redimensionar y convertir
sharp -i input.jpg -o output-400.webp -f webp -q 80 --width 400
sharp -i input.jpg -o output-800.webp -f webp -q 80 --width 800

Paso 7: Memoizacion de Calculos

Antes: Calculo en Cada Render

function App() {
  const [count, setCount] = useState(0)

  // MALO: Se ejecuta en CADA render
  const expensiveValue = heavyCalculation(data)

  return (
    <div>
      <p>Resultado: {expensiveValue}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
    </div>
  )
}

Despues: useMemo para Valores

import { useMemo } from 'react'

function App() {
  const [count, setCount] = useState(0)
  const [data, setData] = useState(initialData)

  // BUENO: Solo recalcula cuando data cambia
  const expensiveValue = useMemo(() => {
    return heavyCalculation(data)
  }, [data])

  return (
    <div>
      <p>Resultado: {expensiveValue}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
    </div>
  )
}

useCallback para Funciones

import { useCallback, memo } from 'react'

// Componente hijo memoizado
const ExpensiveChild = memo(function ExpensiveChild({
  onClick,
  data
}: {
  onClick: () => void
  data: any[]
}) {
  console.log('ExpensiveChild renderizado')
  return <div onClick={onClick}>{/* ... */}</div>
})

function Parent() {
  const [count, setCount] = useState(0)

  // BUENO: Funcion estable entre renders
  const handleClick = useCallback(() => {
    console.log('clicked')
  }, [])

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChild onClick={handleClick} data={stableData} />
    </div>
  )
}

Paso 8: Debouncing de Busqueda

Antes: Busqueda en Cada Keystroke

function SearchBox({ onSearch }) {
  const [term, setTerm] = useState('')

  // MALO: Dispara busqueda en cada tecla
  const handleChange = (e) => {
    setTerm(e.target.value)
    onSearch(e.target.value) // API call en cada keystroke!
  }

  return <input value={term} onChange={handleChange} />
}

Despues: Con Debounce

import { useState, useCallback, useEffect } from 'react'

// Hook personalizado para debounce
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

// Uso en componente
function SearchBox({ onSearch }) {
  const [term, setTerm] = useState('')
  const debouncedTerm = useDebounce(term, 300)

  // Solo busca cuando el valor debounced cambia
  useEffect(() => {
    if (debouncedTerm) {
      onSearch(debouncedTerm)
    }
  }, [debouncedTerm, onSearch])

  return (
    <input
      value={term}
      onChange={(e) => setTerm(e.target.value)}
      placeholder="Buscar..."
    />
  )
}

Con lodash/debounce

import debounce from 'lodash/debounce'
import { useMemo } from 'react'

function SearchBox({ onSearch }) {
  const [term, setTerm] = useState('')

  const debouncedSearch = useMemo(
    () => debounce((value: string) => onSearch(value), 300),
    [onSearch]
  )

  const handleChange = (e) => {
    setTerm(e.target.value)
    debouncedSearch(e.target.value)
  }

  return <input value={term} onChange={handleChange} />
}

Paso 9: Virtualizacion de Listas

Antes: Renderizar Todo

// MALO: 10,000 items = 10,000 nodos DOM
function ProductList({ products }) {
  return (
    <div className="list">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

Despues: Con react-window

npm install react-window
import { FixedSizeList as List } from 'react-window'

function VirtualizedProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  )

  return (
    <List
      height={600}
      itemCount={products.length}
      itemSize={120} // Altura de cada item
      width="100%"
    >
      {Row}
    </List>
  )
}

Para Listas de Altura Variable

import { VariableSizeList as List } from 'react-window'

function VariableProductList({ products }) {
  const getItemSize = (index) => {
    // Calcular altura basada en contenido
    return products[index].description.length > 100 ? 180 : 120
  }

  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  )

  return (
    <List
      height={600}
      itemCount={products.length}
      itemSize={getItemSize}
      width="100%"
    >
      {Row}
    </List>
  )
}

Impacto

Lista de 10,000 productos:

SIN virtualizacion:
- Nodos DOM: 10,000
- Memoria: ~50MB
- Scroll: Laggy (15 FPS)
- Tiempo de render inicial: 2-3s

CON virtualizacion:
- Nodos DOM: ~20 (los visibles + buffer)
- Memoria: ~2MB
- Scroll: Suave (60 FPS)
- Tiempo de render inicial: <100ms

Paso 10: Cache de API

Antes: Fetch en Cada Mount

function ProductList() {
  const [products, setProducts] = useState([])

  // MALO: Fetch cada vez que el componente monta
  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(setProducts)
  }, [])

  return <div>{/* ... */}</div>
}

Despues: Con React Query/TanStack Query

npm install @tanstack/react-query
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutos
      cacheTime: 30 * 60 * 1000, // 30 minutos
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ProductList />
    </QueryClientProvider>
  )
}

function ProductList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(res => res.json()),
  })

  if (isLoading) return <Skeleton />
  if (error) return <Error message={error.message} />

  return <div>{/* productos cacheados */}</div>
}

Cache Manual con SWR Pattern

// Cache simple en memoria
const cache = new Map()

async function fetchWithCache(url: string, ttl = 300000) {
  const cached = cache.get(url)

  if (cached && Date.now() - cached.timestamp < ttl) {
    return cached.data
  }

  const response = await fetch(url)
  const data = await response.json()

  cache.set(url, { data, timestamp: Date.now() })

  return data
}

// Uso
function ProductList() {
  const [products, setProducts] = useState([])

  useEffect(() => {
    fetchWithCache('/api/products', 5 * 60 * 1000)
      .then(setProducts)
  }, [])

  return <div>{/* ... */}</div>
}

Resultados: Antes vs Despues

Lighthouse Scores

ANTES:
======
Performance: 35
LCP: 4.2s
FID: 320ms
CLS: 0.25
Bundle: 1.2MB

DESPUES:
========
Performance: 92
LCP: 1.4s
FID: 45ms
CLS: 0.02
Bundle: 180KB

MEJORAS:
========
Performance: +163% (35 -> 92)
LCP: -67% (4.2s -> 1.4s)
FID: -86% (320ms -> 45ms)
CLS: -92% (0.25 -> 0.02)
Bundle: -85% (1.2MB -> 180KB)

Metricas de Usuario Real

Tiempo de carga percibido:
- Antes: 5+ segundos
- Despues: <2 segundos

Interactividad:
- Antes: App se siente "trabada"
- Despues: Respuesta instantanea

Scroll en listas:
- Antes: 15 FPS, laggy
- Despues: 60 FPS, suave

Performance Budgets

Configurar Presupuestos

Crea un archivo budget.json:

{
  "budgets": [
    {
      "resourceType": "script",
      "budget": 200
    },
    {
      "resourceType": "total",
      "budget": 500
    },
    {
      "metric": "first-contentful-paint",
      "budget": 1500
    },
    {
      "metric": "interactive",
      "budget": 3000
    },
    {
      "metric": "largest-contentful-paint",
      "budget": 2500
    }
  ]
}

Lighthouse CI en GitHub Actions

# .github/workflows/lighthouse.yml
name: Lighthouse CI

on: [push, pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci
      - run: npm run build

      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v10
        with:
          configPath: './lighthouserc.json'
          uploadArtifacts: true
          temporaryPublicStorage: true

lighthouserc.json

{
  "ci": {
    "collect": {
      "staticDistDir": "./dist"
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", {"minScore": 0.9}],
        "first-contentful-paint": ["warn", {"maxNumericValue": 1500}],
        "largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
        "total-blocking-time": ["warn", {"maxNumericValue": 300}],
        "cumulative-layout-shift": ["warn", {"maxNumericValue": 0.1}]
      }
    }
  }
}

Checklist de Auditoria

Usa este checklist para futuras auditorias:

Bundle y Codigo

  • Imports: ¿Estoy importando librerias completas innecesariamente?
  • Code splitting: ¿Los componentes pesados usan lazy loading?
  • Tree shaking: ¿El bundler elimina codigo no usado?
  • Minificacion: ¿JS y CSS estan minificados en produccion?

Imagenes

  • Formato: ¿Uso WebP/AVIF con fallbacks?
  • Tamano: ¿Las imagenes estan dimensionadas correctamente?
  • Lazy loading: ¿Imagenes below-the-fold tienen loading="lazy"?
  • Dimensiones: ¿Todas las imagenes tienen width/height para evitar CLS?

Renderizado

  • Memoizacion: ¿Calculos pesados usan useMemo?
  • Callbacks: ¿Funciones pasadas a hijos usan useCallback?
  • Virtualizacion: ¿Listas largas estan virtualizadas?
  • Debouncing: ¿Inputs de busqueda tienen debounce?

Network

  • Cache: ¿API responses tienen cache apropiado?
  • Prefetch: ¿Recursos criticos usan preload/prefetch?
  • Compresion: ¿Servidor usa gzip/brotli?
  • CDN: ¿Assets estaticos estan en CDN?

Core Web Vitals

  • LCP < 2.5s: ¿El elemento mas grande carga rapido?
  • FID < 100ms: ¿La pagina responde rapido a interacciones?
  • CLS < 0.1: ¿No hay saltos de layout?

Herramientas Recomendadas

HerramientaUso
LighthouseAuditoria general
WebPageTestAnalisis detallado desde multiples ubicaciones
Bundle AnalyzerVer que ocupa espacio en el bundle
Chrome DevTools PerformanceProfiling detallado
React DevTools ProfilerPerformance especifica de React

Proximo Paso

Aplica este proceso a tu propia aplicacion:

  1. Corre Lighthouse y anota el score inicial
  2. Identifica los 3 problemas mas grandes
  3. Aplica las correcciones una por una
  4. Mide despues de cada cambio
  5. Configura performance budgets para mantener las mejoras

Recuerda: medir primero, optimizar despues. No optimices a ciegas.