Performance y deploy
"Una app de 100ms se siente instantánea. Una de 1 segundo se siente lenta. Una de 3 segundos hace que el usuario se vaya."
Qué vas a aprender en este capítulo
Construir la app es la mitad del trabajo. La otra mitad: que sea rápida, que se vea bien en mobile, que se desplegue automáticamente, y que sepamos cuándo se rompe. Este capítulo cubre performance, deploy y monitoreo.
5.1 Core Web Vitals
📐 Fundamento
Google mide la experiencia de usuario con tres métricas principales:
| Métrica | Mide | Objetivo (bueno) |
|---|---|---|
| LCP (Largest Contentful Paint) | Tiempo hasta que se renderiza el elemento más grande | < 2.5s |
| INP (Interaction to Next Paint) | Latencia de interacciones (clicks, tipeo) | < 200ms |
| CLS (Cumulative Layout Shift) | Movimiento visual durante la carga | < 0.1 |
(INP reemplazó a FID — First Input Delay — en marzo 2024.)
Otras métricas importantes:
- FCP (First Contentful Paint): cuándo aparece el primer texto/imagen.
- TTFB (Time to First Byte): respuesta del servidor.
- TBT (Total Blocking Time): tiempo bloqueado por JavaScript.
Medir con Lighthouse:
# CLI
npx lighthouse https://la-esquina.com --view
# O en Chrome DevTools: pestaña Lighthouse → Generate report
Performance score: 0-100. Objetivo: > 90.
Real User Monitoring (RUM) con web-vitals:
import { onCLS, onINP, onLCP, onTTFB } from 'web-vitals';
function enviarMetrica(metric) {
fetch('/api/metricas', {
method: 'POST',
body: JSON.stringify(metric),
keepalive: true // continúa enviando aunque el usuario navegue
});
}
onCLS(enviarMetrica);
onINP(enviarMetrica);
onLCP(enviarMetrica);
5.2 Optimizaciones de frontend
📐 Fundamento
1. Code splitting: dividir el bundle en chunks que se cargan bajo demanda.
// Antes — todo en un bundle
import AdminPanel from './AdminPanel';
// Después — solo se carga cuando se navega a /admin
const AdminPanel = lazy(() => import('./AdminPanel'));
Resultado: el bundle inicial es más pequeño → carga más rápida.
2. Tree shaking: eliminar código no usado en build time.
// MAL — importa la librería completa
import _ from 'lodash';
_.debounce(...)
// BIEN — solo importa lo necesario
import debounce from 'lodash/debounce';
debounce(...)
3. Optimización de imágenes:
<!-- Antes -->
<img src="hero.jpg" />
<!-- Después: WebP/AVIF, lazy loading, responsive -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg"
loading="lazy" <!-- carga cuando entra a viewport -->
width="800" height="400" <!-- evita layout shift -->
alt="Pupusas">
</picture>
4. Caché HTTP agresivo para assets estáticos:
# nginx config
location ~* \.(css|js|png|jpg|webp|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Combinar con content hashing: app-abc123.js cambia cuando el código cambia → URL nueva → invalidación automática.
5. Preload de recursos críticos:
<link rel="preload" href="/fuentes/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preconnect" href="https://api.la-esquina.com">
6. Service Worker para offline / PWA:
// sw.js
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then(res => res || fetch(e.request))
);
});
// En main.js
navigator.serviceWorker.register('/sw.js');
7. Virtualización de listas largas:
Renderizar 10,000 filas en el DOM mata el browser. Solo renderizar las visibles.
import { useVirtualizer } from '@tanstack/react-virtual';
function ListaGrande({ items }) {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(row => (
<div key={row.key} style={{ position: 'absolute', top: row.start }}>
{items[row.index].nombre}
</div>
))}
</div>
</div>
);
}
5.3 CDN y arquitectura de delivery
💡 Intuición
Tu servidor está en San Salvador. Un usuario en Madrid carga tu sitio: la latencia es ~150ms por request. Con un CDN (Content Delivery Network), el usuario en Madrid recibe los assets desde un servidor en España: ~10ms.
📐 Fundamento
Cómo funciona un CDN:
Usuario en Madrid Usuario en San Salvador
↓ ↓
[CDN edge: España] [CDN edge: México]
↓ (si no cachea) ↓
└────────→ [Origin: tu servidor en SS] ←──────┘
Qué cachear en CDN:
- Imágenes, CSS, JS — siempre (con cache hashing).
- Fonts — siempre (cambian raramente).
- HTML — solo si es estático (SSG).
- API responses — solo si son cacheables (públicos, no por usuario).
Cloudflare es el CDN gratuito más usado:
1. Crear cuenta en Cloudflare.
2. Cambiar DNS de tu dominio a los nameservers de Cloudflare.
3. Cloudflare actúa como proxy entre internet y tu servidor.
4. Beneficios automáticos: CDN global, DDoS protection, TLS automático, cache.
Otros CDNs: Fastly, AWS CloudFront, Bunny.net, KeyCDN.
Edge computing: ejecutar código JS en los edges del CDN (cerca del usuario):
// Cloudflare Worker — corre en el edge
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
// Esto corre en el datacenter más cercano al usuario
// Latencia: pocos ms
const url = new URL(request.url);
if (url.pathname === '/api/menu') {
// Cachear el menú por 5 minutos en el edge
const cached = await caches.default.match(request);
if (cached) return cached;
const response = await fetch('https://api.la-esquina.com/menu');
await caches.default.put(request, response.clone());
return response;
}
return fetch(request);
}
5.4 Deploy a producción
📐 Fundamento
Stack deploy moderno:
| Capa | Plataformas |
|---|---|
| Frontend (SPA/SSG) | Vercel, Netlify, Cloudflare Pages, GitHub Pages |
| Backend (API) | Railway, Fly.io, Render, AWS, GCP, DigitalOcean |
| Base de datos | Neon, Supabase, Railway, RDS |
| Storage de archivos | S3, R2 (Cloudflare), Supabase Storage |
| Background jobs | Inngest, Trigger.dev, Celery + Redis |
Deploy con Vercel (frontend React):
# Conectar repo de GitHub a Vercel
vercel # CLI
# vercel.json
{
"framework": "vite",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [
{ "source": "/api/:path*", "destination": "https://api.la-esquina.com/:path*" }
]
}
Push a main → deploy automático en segundos.
Deploy con Docker (backend):
# Dockerfile multistage para backend Python
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
# Usuario no-root por seguridad
RUN useradd -m appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
# docker-compose.yml para desarrollo local
version: '3.8'
services:
backend:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgresql://user:pass@db:5432/esquina
REDIS_URL: redis://redis:6379
depends_on: [db, redis]
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: esquina
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
volumes:
pgdata:
Variables de entorno por ambiente:
# .env.development
DATABASE_URL=postgresql://localhost:5432/dev
DEBUG=true
# .env.production
DATABASE_URL=postgresql://prod-host/prod
DEBUG=false
SENTRY_DSN=https://...
Nunca commitear secretos: .env en .gitignore. Usar secret managers en producción (AWS Secrets Manager, HashiCorp Vault, Doppler).
Health checks y zero-downtime deploys:
@app.get("/health")
def health():
return {"status": "ok", "version": os.environ.get("APP_VERSION", "dev")}
@app.get("/ready")
def ready():
# Verificar dependencias críticas
try:
db.execute("SELECT 1")
redis.ping()
return {"status": "ready"}
except Exception as e:
return JSONResponse({"status": "not ready", "error": str(e)}, status_code=503)
Kubernetes / load balancer usa estos endpoints para hacer rolling updates sin downtime.
5.5 Monitoreo y observabilidad
📐 Fundamento
Tres pilares (recordatorio del capítulo 4 de SO):
- Logs — qué pasó. Sentry para errores, Logtail/Datadog para todo.
- Métricas — cuánto. Prometheus + Grafana o Datadog.
- Trazas — dónde tardó. Jaeger, Zipkin, OpenTelemetry.
Sentry para frontend y backend:
// React
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
tracesSampleRate: 0.1, // 10% de transactions
replaysSessionSampleRate: 0.01 // 1% de sesiones grabadas para replay
});
// Capturar errores manualmente
try {
hacerAlgo();
} catch (e) {
Sentry.captureException(e);
}
# FastAPI
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
sentry_sdk.init(
dsn=os.environ['SENTRY_DSN'],
integrations=[FastApiIntegration()],
traces_sample_rate=0.1,
environment=os.environ['ENV']
)
Métricas custom (Prometheus):
from prometheus_client import Counter, Histogram, generate_latest
pedidos_creados = Counter('pedidos_creados_total', 'Total de pedidos creados', ['local'])
duracion_request = Histogram('http_request_duration_seconds', 'Duración', ['endpoint'])
@app.post("/pedidos")
def crear(pedido):
with duracion_request.labels('crear_pedido').time():
nuevo = db.crear(pedido)
pedidos_creados.labels(pedido.local).inc()
return nuevo
@app.get("/metrics")
def metrics():
return Response(generate_latest(), media_type="text/plain")
Alertas:
# Alertmanager / Grafana
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
for: 5m
annotations:
summary: "Error rate > 5% por 5 minutos"
- alert: HighLatency
expr: histogram_quantile(0.95, http_request_duration_seconds) > 2
for: 10m
annotations:
summary: "P95 latency > 2s"
🛠️ En la práctica
La Esquina App: arquitectura de deploy completa
┌─────────────────────────────────────────────────────────┐
│ Usuarios │
└─────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Cloudflare CDN+WAF │ ← cache, DDoS, TLS
└──────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Frontend │ │ Backend API │
│ Vercel │ │ Railway │
│ React+Vite │ │ FastAPI │
│ app.la- │ │ api.la- │
│ esquina.com │ │ esquina.com │
└──────────────┘ └──────────────┘
│
┌───────────┴───────────┐
▼ ▼
┌─────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │
│ (Neon) │ │ (Upstash) │
└─────────────┘ └──────────────┘
│
▼
┌─────────────┐
│ Sentry │ ← errores
│ Posthog │ ← analytics
└─────────────┘
CI/CD:
- Push a main → GitHub Actions corre tests
- Tests pasan → Vercel + Railway despliegan automáticamente
- Vercel preview deployments para cada PR
Costos estimados (escala pequeña):
- Vercel: free tier (Hobby)
- Railway: $5/mes
- Neon Postgres: free tier
- Upstash Redis: free tier
- Cloudflare: free tier
- Sentry: free tier (5K errors/mes)
Total: ~$5/mes para empezar.
5.6 Ejercicios
✏️ Ejercicio 5.1 — Auditoría de performance
Tu sitio La Esquina App tiene estos resultados de Lighthouse:
- LCP: 4.2s (objetivo < 2.5s)
- INP: 350ms (objetivo < 200ms)
- CLS: 0.25 (objetivo < 0.1)
- Performance score: 52/100
- Bundle JS: 1.2MB
Diagnosticá las causas probables y propone 5 optimizaciones concretas en orden de prioridad.
Solución
Diagnóstico:
| Métrica | Causa probable |
|---|---|
| LCP 4.2s | Imagen hero no optimizada, sin lazy loading o sin preload |
| INP 350ms | Demasiado JS bloqueando el main thread |
| CLS 0.25 | Imágenes sin width/height; fonts sin font-display: swap causando FOIT |
| Bundle 1.2MB | Falta code splitting, librerías importadas completas |
5 optimizaciones priorizadas:
-
Code splitting + lazy loading de rutas (impacta LCP, INP, bundle):
const AdminPanel = lazy(() => import('./AdminPanel'));Esperado: bundle inicial baja a ~400KB.
-
Optimizar imagen hero (impacta LCP):
- Convertir a WebP (50% menos peso).
- Servir en tamaño correcto:
<img srcset>con resoluciones distintas. <link rel="preload" as="image" href="/hero.webp">. Esperado: LCP baja a ~2s.
-
Reservar espacio para imágenes con
width/height(impacta CLS):<img src="..." width="800" height="400" />Esperado: CLS baja a < 0.1.
-
Tree shaking de librerías (impacta bundle, INP):
- Reemplazar
import _ from 'lodash'por imports específicos. - Auditar dependencias con
npm lsywebpack-bundle-analyzer. Esperado: bundle baja otros 200KB.
- Reemplazar
-
Cargar JS no crítico de forma defer/async (impacta INP):
<script src="/analytics.js" defer></script>Mover analytics y otros scripts no críticos fuera del path crítico. Esperado: INP baja a < 200ms.
5.7 Cierre del libro
Este libro recorrió el panorama del desarrollo web moderno:
- APIs REST — el modelo dominante para APIs.
- GraphQL — alternativa flexible para UIs complejas.
- Frontend moderno con React — SPAs con hooks y TanStack Query.
- Tiempo real con WebSockets — colaboración y notificaciones live.
- Performance y deploy — hacer que sea rápido y llevarlo a producción.
La web evoluciona constantemente. Las herramientas cambiarán, pero los principios — performance, accesibilidad, seguridad, observabilidad — son los que perduran.
5.8 Para profindizar
- web.dev — Google's web performance hub.
- frontendmasters.com — cursos profundos.
- Vercel docs, Railway docs, Cloudflare docs.
- The Twelve-Factor App (12factor.net) — principios de apps cloud-native.
5.X Mini-proyecto integrador
🏗️ Proyecto final — Una app web full-stack en producción
Alcance: construir y desplegar una app web completa que use todo el libro: API REST, GraphQL, frontend moderno, tiempo real y observabilidad. El producto no es lo importante — es la stack.
Idea sugerida: un mini-Twitter para tu carrera (post, follow, feed en tiempo real). O un sistema de turnos online de la pupusería con notificaciones live.
Entregables (1 monorepo):
- Backend (cap. 1) — FastAPI con al menos 5 endpoints REST documentados con OpenAPI.
- GraphQL (cap. 2) — un endpoint
/graphqlpara queries complejas (feed paginado con filtros). - Frontend (cap. 3) — React/Vue/Svelte. Routing, estado global, formularios.
- Tiempo real (cap. 4) — WebSocket para notificaciones live de nuevos posts/turnos.
- Performance (cap. 5) — Lighthouse score > 90 en mobile, code splitting, lazy loading de rutas.
- Deploy (cap. 5) — Vercel/Railway/Fly.io. Health check
/health, métricas básicas, Sentry para errores. - CI mínimo: lint + tests + build en cada PR.
Criterio de éxito: podés mandar el link a un amigo, lo abre desde su celular, crea una cuenta y publica un post — todo en menos de 30 segundos.
Tiempo estimado: 4-6 semanas, ideal en pareja. Es el proyecto bisagra para conseguir trabajo de fullstack.
Definiciones nuevas: Core Web Vitals, LCP, INP, CLS, Lighthouse, Real User Monitoring (RUM), code splitting, tree shaking, lazy loading, Service Worker, PWA, virtualización, CDN, edge computing, Cloudflare Workers, deploy continuo, health check, observabilidad, Sentry, Prometheus, alertas.