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
- Abre Chrome DevTools (F12)
- Ve a la pestana Lighthouse
- Selecciona:
- Mode: Navigation
- Device: Mobile
- Categories: Performance, Best Practices, SEO
- 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
| Problema | Causa | Impacto |
|---|---|---|
| Alto TBT | Calculo expensivo bloqueando main thread | FID malo |
| Alto LCP | Imagenes grandes, JS bloqueante | UX lenta |
| Alto CLS | Imagenes sin dimensiones | Saltos visuales |
Paso 2: Performance Tab de DevTools
Grabar una Sesion
- Abre DevTools > Performance
- Click en el boton de grabar (circulo)
- Interactua con la app (scroll, buscar, etc.)
- Click en stop
- 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:
- expensiveCalculation() - 200ms+ de bloqueo
- Moment.js parsing - 50ms+ en cada render
- Product filtering - Crece con mas productos
Paso 3: Network Waterfall
Analizar Carga de Recursos
- DevTools > Network
- Recarga la pagina (Ctrl+Shift+R para cache limpio)
- 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
- Bundle enorme: 800KB de JS en un solo archivo
- Carga secuencial: Imagenes esperan al JS
- Sin compresion: Imagenes en formato original
- 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
| Herramienta | Uso |
|---|---|
| Lighthouse | Auditoria general |
| WebPageTest | Analisis detallado desde multiples ubicaciones |
| Bundle Analyzer | Ver que ocupa espacio en el bundle |
| Chrome DevTools Performance | Profiling detallado |
| React DevTools Profiler | Performance especifica de React |
Proximo Paso
Aplica este proceso a tu propia aplicacion:
- Corre Lighthouse y anota el score inicial
- Identifica los 3 problemas mas grandes
- Aplica las correcciones una por una
- Mide despues de cada cambio
- Configura performance budgets para mantener las mejoras
Recuerda: medir primero, optimizar despues. No optimices a ciegas.