Saltar al contenido

Web · Avanzado

Preparar tu app para miles de usuariosseis revisiones antes de que el tráfico la tire

Hay dos momentos en que una app te avisa que no aguanta: cuando la tabla principal pasa de miles a cientos de miles de filas, y cuando entra tráfico real de golpe (una campaña, un evento, un webinar). En los dos casos el síntoma es el mismo (todo se pone lento y luego empieza a fallar) y la causa casi nunca está donde miras primero. Lo que sigue no es teoría de escalabilidad: son las seis revisiones que aplico, en este orden, cada vez que un proyecto va a recibir más carga de la que ha visto. Cada una tiene una forma de medir antes de tocar nada, porque el error más caro que cometí fue optimizar lo que no era el cuello de botella.

Publicada 28 de agosto de 2026Lectura 34 minRequiere saber programar

De un vistazo

01

Primero el mapa de riesgos, después optimizar

02

Índices, caché y lo pesado fuera del request

03

La rompes tú con carga antes de que la rompa el tráfico

01 el punto de partida

Antes de optimizar: el mapa de riesgos

La regla más importante de todo el procedimiento: diagnóstico antes de código. Cuando algo va lento la tentación es meter caché en todos lados o agregar índices por si acaso. Las dos cosas empeoran la situación si no eran el problema: la caché sirve datos viejos y cada índice hace más lentas las escrituras. Así que la primera revisión no arregla nada. Solo produce una tabla.

Antes de la tabla, confirma el stack en una línea (framework, dónde vive la base, hosting, si hay caché o cola disponible). Esta guía asume el que uso en casi todo: Next.js con App Router, Postgres en Supabase, Vercel. Si el tuyo es otro, las seis revisiones son las mismas; cambian los comandos.

Los cinco riesgos que buscas

Queries sin índice o sin paginación; cosas que se calculan repetido y no se guardan; operaciones pesadas dentro del request; endpoints públicos sin límite; y cero forma de enterarte cuando algo falla en producción.

La salida esperada

Una tabla con riesgo, severidad, archivo, qué pasa cuando lleguen 100 y luego 1,000 usuarios, y cuál de las revisiones lo resuelve. Ordenada por lo que se cae primero, no por lo que es más fácil de arreglar.

Lo que se marca como seguridad

Un endpoint público sin autenticación ni límite de peticiones no es un problema de escala futuro: es un problema hoy. Se separa del resto y se atiende antes de cualquier optimización.

Si el proyecto está en Vercel hay tres cosas que reviso antes de cualquier deploy de prueba, porque las tres me han roto producción: que el repo apunte al proyecto correcto (.vercel/project.json), que las variables de entorno existan también en Preview (no se heredan de Production, y el preview responde 500 al primer query), y que el dominio se reasigne solo al hacer merge (si el auto-alias está apagado, el dominio sigue sirviendo el deploy anterior).

02 revisión 1

Queries que hacen scan completo

Síntoma: una pantalla que abría en 200 ms ahora tarda 3 segundos, y no cambiaste nada. Lo que cambió fue la tabla: pasó de 5 mil filas a 500 mil, y una query que filtra por una columna sin índice lee la tabla entera cada vez. Con pocas filas Postgres ni se molesta en usar índices; con muchas, el scan completo es lo que te mata.

Diagnóstico: Postgres lleva la cuenta de cuántas veces leyó cada tabla de corrido (seq_scan) y cuántas por índice (idx_scan) en la vista pg_stat_user_tables. Una tabla grande con muchos scans secuenciales y pocos por índice es la lista de sospechosos:

Atajo: clonar el repo de esta guía (terminal)
git clone https://github.com/davidiriza-lab/escalar-app-kit.git
Repo público con licencia MIT: github.com/davidiriza-lab/escalar-app-kit. Trae los archivos de abajo listos para copiar a tu proyecto.
Tablas grandes que se leen sin índice
select
  relname                              as tabla,
  n_live_tup                           as filas_aprox,
  seq_scan                             as scans_secuenciales,
  seq_tup_read                         as filas_leidas_en_scans,
  idx_scan                             as scans_por_indice,
  round(100.0 * seq_scan / nullif(seq_scan + idx_scan, 0), 1) as pct_secuencial
from pg_stat_user_tables
where n_live_tup > 10000
order by seq_tup_read desc
limit 20;

La columna que importa es seq_tup_read: cuántas filas se han leído en scans completos. Una tabla de 800 mil filas con 40 mil scans secuenciales son 32 mil millones de filas leídas sin necesidad. Para saber qué query lo provoca, pg_stat_statements (extensión aparte, viene activa en Supabase) te da las más costosas por tiempo total:

Las diez queries que más tiempo se llevan
select
  calls,
  round(total_exec_time::numeric / 1000, 1) as segundos_totales,
  round(mean_exec_time::numeric, 1)         as ms_promedio,
  rows,
  left(query, 120)                          as query
from pg_stat_statements
order by total_exec_time desc
limit 10;

Arreglo: un índice por cada combinación de columnas que las queries reales usan para filtrar, ordenar o unir. No uno por columna. Crea el índice con concurrently para no bloquear escrituras mientras se construye, y mide antes y después con explain analyze:

Índice para una query típica de listado
-- La query: contactos de una cuenta, los más recientes primero
-- select * from contacts where account_id = $1 order by created_at desc limit 50;

explain analyze
select * from contacts where account_id = 'x' order by created_at desc limit 50;
-- Antes: Seq Scan on contacts ... actual time=0.03..1840.21 rows=50

create index concurrently if not exists contacts_account_created_idx
  on contacts (account_id, created_at desc);

-- Después: Index Scan using contacts_account_created_idx ... actual time=0.04..0.31 rows=50
  • El orden de las columnas importa: primero la de igualdad (account_id), después la de orden (created_at). Al revés el índice sirve mucho menos.
  • Si una query siempre filtra un subconjunto (where deleted_at is null), un índice parcial con ese where es más chico y más rápido.
  • Búsqueda de texto (nombre, correo con ilike '%...%') no la arregla un índice normal. Ahí el camino es pg_trgm con un índice gin. En el CRM de 800 mil contactos la búsqueda tardaba de 2 a 5 segundos y ningún índice btree lo cambiaba.
  • Los índices se aplican con SQL directo, sin deploy. Es la revisión con más ganancia por menos riesgo; casi siempre vale la pena empezar aquí.

La otra mitad: N+1 y traer de más

Hay una pantalla lenta que ningún índice arregla, y es la más común en código generado con IA: una lista de 100 elementos que hace una query por elemento. La query individual tarda 5 ms y está indexada; el problema es que son 101 viajes a la base en vez de uno. En pg_stat_statements se ve como una query con muchísimas llamadas y tiempo promedio ridículo: por eso el diagnóstico de arriba ordena por tiempo total y no por promedio.

N+1 y su arreglo (postgres.js; en un ORM es cargar la relación en la misma consulta)
// N+1: 100 eventos = 101 queries, una por iteración
const eventos = await db`select id, nombre from eventos where activo = true`;
for (const e of eventos) {
  const [c] = await db`select count(*) from registrations where event_id = ${e.id}`;
  // ...
}

// Una sola query: el conteo viene agrupado desde la base
const filas = await db`
  select e.id, e.nombre, count(r.id) as registros
  from eventos e
  left join registrations r on r.event_id = e.id
  where e.activo = true
  group by e.id, e.nombre
  order by e.nombre
  limit 100
`;
  • Dónde buscarlo: cualquier await dentro de un for, un .map con queries adentro, o un componente de lista que consulta por fila. Con Prisma o Drizzle, la señal es una relación que se lee en un bucle en vez de cargarse con include o with en la consulta principal.
  • Traer de más es el primo del N+1: select * en una tabla con columnas jsonb o de texto largo, o una lista sin limit que devuelve toda la tabla al navegador. Pide solo las columnas que la pantalla usa y pagina siempre; una lista sin límite funciona hasta el día que la tabla crece.
  • La combinación mortal es N+1 sobre una columna sin índice: cada una de las 100 queries hace scan completo. Se arregla en este orden: primero una sola query, después el índice que esa query necesita.

03 revisión 2

El request nunca computa

Síntoma: el dashboard tarda proporcionalmente a cuánta historia tiene el proyecto. Al principio abría rápido; a los seis meses tarda 10 segundos; el rango 'todo el tiempo' ya no carga. Los índices ayudan poco porque el problema no es encontrar filas sino sumar millones de ellas en cada carga.

El caso que me lo enseñó: un panel sobre un CRM con más de 800 mil contactos y 1.4 millones de puntos de contacto. La vista 'Todo' tardaba 18 segundos. El asesino no era la suma de registros ni de compras: era un conteo por etapa del ciclo de vida que recorría las 800 mil filas de contactos en cada carga, para todos los usuarios, aunque el resultado cambiaba unas pocas veces al día.

Diagnóstico: mide cuánto tarda cada agregado del dashboard por separado con explain analyze, y pregúntate para cada uno: ¿este número cambia entre una carga y la siguiente? Si la respuesta es 'los días pasados no cambian', ese agregado no tiene por qué calcularse en el request.

Arreglo: el patrón de rollup diario. Una tabla con un renglón por día y las métricas ya sumadas; un cron que la mantiene; y la función que sirve al dashboard suma renglones chicos para los días cerrados y calcula en vivo solo el día de hoy (que es pequeño y está indexado). Cualquier rango es la suma de unos cientos de filas.

Tabla de rollup + función que la mantiene
create table if not exists stats_daily (
  day            date primary key,   -- día en la zona horaria del negocio
  registrations  integer not null default 0,
  purchases      integer not null default 0,
  revenue_cents  bigint  not null default 0,
  new_contacts   integer not null default 0,
  refreshed_at   timestamptz not null default now()
);

-- Recalcula los últimos N días (captura datos que llegan tarde: refunds, syncs)
create or replace function refresh_stats_daily(p_days integer default 4)
returns void language sql as $$
  insert into stats_daily (day, registrations, purchases, revenue_cents, new_contacts, refreshed_at)
  select
    d.day,
    (select count(*) from registrations r
       where (r.created_at at time zone 'America/Mexico_City')::date = d.day),
    (select count(*) from purchases p
       where (p.paid_at at time zone 'America/Mexico_City')::date = d.day),
    (select coalesce(sum(p.amount_cents), 0) from purchases p
       where (p.paid_at at time zone 'America/Mexico_City')::date = d.day),
    (select count(*) from contacts c
       where (c.created_at at time zone 'America/Mexico_City')::date = d.day),
    now()
  from generate_series(
    (now() at time zone 'America/Mexico_City')::date - (p_days - 1),
    (now() at time zone 'America/Mexico_City')::date,
    interval '1 day'
  ) as d(day)
  on conflict (day) do update set
    registrations = excluded.registrations,
    purchases     = excluded.purchases,
    revenue_cents = excluded.revenue_cents,
    new_contacts  = excluded.new_contacts,
    refreshed_at  = excluded.refreshed_at;
$$;
Lo que el dashboard consulta: días congelados + hoy en vivo
create or replace function dashboard_stats(p_from date, p_to date)
returns table (registrations bigint, purchases bigint, revenue_cents bigint)
language plpgsql stable as $$
declare
  v_hoy        date        := (now() at time zone 'America/Mexico_City')::date;
  v_inicio_hoy timestamptz := (v_hoy::timestamp at time zone 'America/Mexico_City');
  v_reg bigint := 0; v_pur bigint := 0; v_rev bigint := 0;
begin
  -- Días cerrados: suma de renglones chicos, ya calculados
  select coalesce(sum(s.registrations), 0), coalesce(sum(s.purchases), 0), coalesce(sum(s.revenue_cents), 0)
    into v_reg, v_pur, v_rev
  from stats_daily s
  where s.day >= p_from and s.day <= p_to and s.day < v_hoy;

  -- Solo hoy en vivo (pocas filas, indexadas por created_at / paid_at)
  if p_to >= v_hoy then
    v_reg := v_reg + (select count(*) from registrations r where r.created_at >= v_inicio_hoy);
    v_pur := v_pur + (select count(*) from purchases p where p.paid_at >= v_inicio_hoy);
    v_rev := v_rev + (select coalesce(sum(p.amount_cents), 0) from purchases p where p.paid_at >= v_inicio_hoy);
  end if;

  return query select v_reg, v_pur, v_rev;
end;
$$;

Con esto el panel pasó de 18,000 ms a 78 ms. El cron que ya sincronizaba datos cada 15 minutos llama refresh_stats_daily(4) al final; recalcular 4 días cubre compras que llegan tarde y reembolsos. El conteo por etapa se volvió una foto que el mismo cron guarda en una tabla de caché.

  • Antes de confiar en el rollup, reconcílialo contra el conteo directo para un rango conocido. Un día desfasado por zona horaria te da números 'casi' correctos, que es lo peor que puede pasar.
  • Bucketea por día con at time zone del negocio, no en UTC. Las 11 de la noche de tu ciudad es mañana en UTC.
  • Trampa que costó un bug: si el rango recibe el límite superior como exclusivo (p_to = mañana 00:00) y 'todo el tiempo' pasa now(), el día de hoy se descarta. Decide una convención y respétala en todas las funciones.
  • La regla general que quedó escrita: días cerrados, rollup congelado; solo hoy en vivo. Nunca una agregación sobre toda la historia dentro del request.

04 revisión 3

Lo que se calcula igual mil veces

Síntoma: la base muestra la misma query cientos de veces por minuto con los mismos parámetros. Cien usuarios abriendo el mismo dashboard son cien veces el mismo cálculo. Aun con rollups, hay respuestas que no necesitan recomputarse más de una vez por minuto.

Diagnóstico: en pg_stat_statements, ordena por calls en lugar de tiempo total. Las queries con miles de llamadas y resultado que casi no cambia (configuración, catálogos, agregados de dashboard, respuestas de APIs externas) son candidatas. Antes de cachear, llena esta tabla para cada una: qué se guarda, dónde, por cuánto tiempo, con qué llave, y qué evento la invalida. Si no puedes responder la última columna, no la caches.

QuéDóndeTTLLlaveSe invalida cuando
Stats del dashboardunstable_cache de Next.js60 srango de fechasCorre el cron de sync (revalidateTag)
Catálogo de productosunstable_cache1 hsin parámetrosSe edita un producto
Respuesta de API externa de tipo de cambiounstable_cache10 minmonedaSolo por TTL
Datos de un usuario logueadoNo se cachea entre usuarios--Riesgo de servir datos de otro

Arreglo en Next.js sin agregar dependencias: unstable_cache envuelve cualquier función async que no use fetch (una query a la base, por ejemplo). Recibe la función, un prefijo de llave y opciones con revalidate en segundos y tags para invalidar a demanda. Los argumentos de la función entran automáticamente en la llave.

lib/cached.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";

export interface DashboardStats {
  registrations: number;
  purchases: number;
  revenueCents: number;
}

async function fetchDashboardStats(from: string, to: string): Promise<DashboardStats> {
  const rows = await db`select * from dashboard_stats(${from}::date, ${to}::date)`;
  const row = rows[0] as { registrations: string; purchases: string; revenue_cents: string };
  return {
    registrations: Number(row.registrations),
    purchases: Number(row.purchases),
    revenueCents: Number(row.revenue_cents),
  };
}

export const getDashboardStats = unstable_cache(fetchDashboardStats, ["dashboard-stats"], {
  revalidate: 60,
  tags: ["dashboard"],
});
app/api/cron/sync/route.ts (invalida al terminar)
import { revalidateTag } from "next/cache";
import { db } from "@/lib/db";

export async function GET(request: Request): Promise<Response> {
  const auth = request.headers.get("authorization");
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  await db`select refresh_stats_daily(4)`;
  revalidateTag("dashboard", "max");

  return Response.json({ ok: true, refreshedAt: new Date().toISOString() });
}
  • Las queries independientes de una misma página van en Promise.all, no en secuencia. Un dashboard con seis consultas de 300 ms tarda 1.8 s en serie y 300 ms en paralelo. Es la ganancia más barata que existe en Next.js.
  • Promise.all con escrituras (UPDATE en lote) es otra historia: bonito en teoría, frágil con el pooler bajo carga. Empieza con escrituras individuales y optimiza solo con evidencia.
  • Nunca metas datos de un usuario en una caché cuya llave no incluya al usuario. Es la forma más rápida de enseñarle a alguien el panel de otro.
  • Si necesitas caché compartida entre funciones serverless (contadores, límites de peticiones), la memoria del proceso no sirve: cada instancia tiene la suya. Ahí entra Redis externo (Upstash es la opción habitual en Vercel).

05 revisión 4

Lo pesado fuera del request

Síntoma: el formulario de registro tarda 4 segundos en responder porque dentro del mismo request se manda un correo, se avisa a un CRM externo y se dispara un evento a una plataforma de anuncios. Con 10 registros por hora nadie lo nota. Con 50 por minuto, cada API externa lenta se convierte en tu latencia, y cuando una falla, el usuario ve un error aunque su registro sí se guardó.

Diagnóstico: en cada API route de escritura, lista lo que pasa después de guardar en la base y clasifícalo. Crítico es lo que no puede perderse (el registro, un pago, un log de cobro). Best-effort es lo que, si se pierde, se puede reintentar o no importa (un evento de analytics, una notificación interna). Cada categoría va a un sitio distinto.

Arreglo: la escritura crítica se espera; lo demás se lanza sin esperar. En Vercel eso tiene una trampa: cuando la función responde, la instancia puede congelarse antes de que termine lo que dejaste corriendo. after() de Next.js promete ejecutar trabajo después de la respuesta, pero en producción bajo carga vi inserts dentro de after() que nunca llegaron: 200 OK y la fila no existe. waitUntil del paquete @vercel/functions es la garantía oficial de que la instancia sigue viva hasta que la promesa termine.

app/api/register/route.ts
import { waitUntil } from "@vercel/functions";
import { db } from "@/lib/db";
import { notifyCrm, sendCapiEvent } from "@/lib/integrations";

export const maxDuration = 10;

interface RegisterBody {
  email: string;
  name: string;
  phone?: string;
}

function isRegisterBody(value: unknown): value is RegisterBody {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.email === "string" && typeof v.name === "string";
}

export async function POST(request: Request): Promise<Response> {
  const body: unknown = await request.json().catch(() => null);
  if (!isRegisterBody(body)) {
    return Response.json({ error: "invalid body" }, { status: 400 });
  }

  // 1. Crítico: se espera. Si falla, el usuario lo sabe.
  const [row] = await db`
    insert into registrations (email, name, phone)
    values (${body.email}, ${body.name}, ${body.phone ?? null})
    on conflict (email) do update set name = excluded.name
    returning id
  `;
  const id = (row as { id: string }).id;

  // 2. Best-effort: no bloquea la respuesta, pero la instancia no se congela hasta que termine.
  waitUntil(
    Promise.allSettled([notifyCrm({ id, ...body }), sendCapiEvent({ id, email: body.email })]).then(
      (results) => {
        for (const r of results) {
          if (r.status === "rejected") console.error("[register] side effect failed", r.reason);
        }
      },
    ),
  );

  return Response.json({ ok: true, id });
}

Lo que sí cabe en waitUntil es lo que termina en segundos. Un reporte que tarda minutos, un PDF, un envío masivo de mensajes, necesitan una cola con reintentos y un worker: en Vercel no hay procesos persistentes, así que la cola es un servicio (Inngest, Trigger.dev, QStash). En un VPS con Node, BullMQ sobre Redis. Cada job debe poder reintentarse sin duplicar efecto: la llave de idempotencia (id del registro, id del pago) va en el job, y el worker verifica antes de actuar.

Cola mínima con reintentos e idempotencia (BullMQ sobre Redis, para un worker en Node)
import { Queue, Worker } from "bullmq";

const connection = { url: process.env.REDIS_URL ?? "" };

export const reportes = new Queue("reportes", {
  connection,
  defaultJobOptions: {
    attempts: 3,                                        // 3 intentos en total
    backoff: { type: "exponential", delay: 2000 },      // 2 s, 4 s, 8 s
    removeOnComplete: 1000,                             // conserva los últimos 1,000 exitosos
  },
});

// jobId = llave de idempotencia: el mismo registro no se encola dos veces
export async function encolarPdf(registroId: string): Promise<void> {
  await reportes.add("pdf", { registroId }, { jobId: `pdf-${registroId}` });
}

// El worker vive en un proceso aparte (VPS, Railway, contenedor), no en Vercel
new Worker(
  "reportes",
  async (job) => {
    const { registroId } = job.data as { registroId: string };
    // 1. verificar que no se hizo ya (por si el reintento llega después de un éxito parcial)
    // 2. generar el PDF, guardarlo, marcar el registro
  },
  { connection },
);
  • Los jobs que agotan sus intentos se quedan en el conjunto de fallidos de la cola. Ese conjunto es tu dead-letter: alguien tiene que mirarlo (una alerta cuando crece, ver revisión 6) o los fallos se acumulan en silencio.
  • El usuario necesita saber en qué va su reporte. Guarda el estado del job en tu tabla (pendiente, listo, falló) y que la pantalla lo consulte; no lo hagas esperar dentro del request.
  • Si tu app vive solo en Vercel y no quieres operar un worker, el equivalente gestionado son Inngest, Trigger.dev o QStash: el mismo contrato (reintentos, idempotencia por id, fallidos visibles), sin proceso propio.

Los límites de duración de Vercel al escribir esto: 300 segundos por defecto en todos los planes; en Hobby es también el máximo; Pro y Enterprise pueden subir a 800 segundos por función con export const maxDuration, y hasta 1,800 en beta configurándolo función por función. Un endpoint de usuario que necesita más de 10 segundos no es un problema de timeout: es una operación que debe salir del request.

06 revisión 5

Límites: los tuyos y los de los demás

Síntoma: 'desde las 3 de la mañana no caen los registros'. Nada tiró un error visible; una integración simplemente dejó de escribir. Esta revisión cubre dos cosas que se parecen: los límites que tú no pusiste (y que un bot o una campaña van a encontrar por ti) y los límites que las plataformas te ponen (y que un proceso tuyo va a agotar por ti).

El incidente que lo resume

Un proceso de relleno histórico escribía registros a hojas de cálculo compartidas con el equipo de ventas. Dos errores juntos: no tenía piso de fecha (bajó registros de tres meses atrás a hojas que solo debían tener la semana), y escribía en ráfagas de cien filas leyendo el documento completo cada vez. Eso agotó la cuota por minuto de la misma cuenta de servicio que usaba el escritor en vivo. Resultado: los leads dejaron de llegar a las hojas del equipo durante horas, y la limpieza se llevó filas que los vendedores ya habían trabajado. Un día completo de restauración.

Los candados que quedaron y que ahora aplico en cualquier proceso que escribe a una API externa: piso de fecha explícito, presupuesto global de escrituras por corrida (100), una pausa entre filas (1.1 s), y una marca de agua que solo avanza si la escritura fue confirmada. Ante fallo, se frena ese destino y la marca no se mueve: la siguiente corrida retoma donde quedó.

lib/budget.ts: presupuesto por corrida para cualquier escritor externo
export interface WriteBudget {
  /** Escrituras permitidas en esta corrida. */
  max: number;
  /** Pausa entre escrituras en milisegundos. */
  gapMs: number;
  /** No procesar nada anterior a esta fecha (ISO). */
  floorIso: string;
}

export function createBudget(b: WriteBudget): { take: () => boolean; used: () => number } {
  let used = 0;
  return {
    take: () => {
      if (used >= b.max) return false;
      used += 1;
      return true;
    },
    used: () => used,
  };
}

export async function writeWithBudget<T extends { occurredAt: string }>(
  items: T[],
  budget: WriteBudget,
  write: (item: T) => Promise<void>,
  advanceWatermark: (iso: string) => Promise<void>,
): Promise<{ written: number; stoppedAt: string | null }> {
  const b = createBudget(budget);
  for (const item of items) {
    if (item.occurredAt < budget.floorIso) continue;
    if (!b.take()) return { written: b.used(), stoppedAt: item.occurredAt };
    try {
      await write(item);
      await advanceWatermark(item.occurredAt);
    } catch (err) {
      console.error("[writer] fallo, marca de agua no avanza", err);
      return { written: b.used(), stoppedAt: item.occurredAt };
    }
    await new Promise((r) => setTimeout(r, budget.gapMs));
  }
  return { written: b.used(), stoppedAt: null };
}

Los otros tres límites que te van a alcanzar

  • Cuotas de cómputo en planes gratuitos. Un cron de 1 minuto es un proceso que consulta 24/7: anula la suspensión automática de la base y se come la cuota mensual en días. Me pasó dos veces con una base gratuita antes de aprender la regla: antes de montar cualquier polling sobre infraestructura medida, calcula el consumo y ofrece un intervalo más largo o una alerta de uso. La caída fue total y evitable.
  • El límite de filas de la API de Supabase. PostgREST devuelve como máximo 1,000 filas por petición aunque pidas más (max_rows en la configuración del proyecto). Un dashboard que 'cuenta' con .select() y mide el largo del arreglo va a decir 1,000 para siempre. Para contar, usa { count: 'exact', head: true }; para paginar, .range(). Lo aprendí cuando un panel se congeló en mil registros durante una semana sin que nadie lo notara.
  • Límite de peticiones en tus endpoints públicos. Un formulario sin límite es un endpoint que cualquier script puede llenar de basura. Un contador en memoria funciona por instancia (cada función serverless tiene su propio Map), así que es una defensa básica, no global. Para protección real, un límite distribuido en Redis (Upstash Ratelimit).
Contar sin traer filas (cliente de Supabase)
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(process.env.SUPABASE_URL ?? "", process.env.SUPABASE_SERVICE_ROLE_KEY ?? "");

export async function countRegistrations(since: string): Promise<number> {
  const { count, error } = await supabase
    .from("registrations")
    .select("*", { count: "exact", head: true })
    .gte("created_at", since);
  if (error) throw error;
  return count ?? 0;
}

07 revisión 6

Romperla tú antes de que la rompan

Síntoma: no hay síntoma todavía. Ese es el punto. Las cinco revisiones anteriores arreglan lo que encontraste leyendo código y estadísticas; esta encuentra lo que no viste, subiendo la carga poco a poco hasta que algo cede. La herramienta es k6: un script en JavaScript que simula usuarios, y umbrales que hacen que la prueba pase o falle sola.

Diagnóstico: el script pega a las rutas que un usuario real toca en orden (home, página del evento, registro), no solo al home. Sube en escalones (10, 100, 500, 1,000 usuarios) y busca el punto donde los tiempos se disparan o aparecen errores. Los umbrales definen 'aceptable': aquí, el 95% de las respuestas bajo 500 ms y menos del 1% de fallos.

Instalar k6 (macOS)
brew install k6 && k6 version
scripts/loadtest/k6-flujo.js
import http from "k6/http";
import { check, sleep } from "k6";

// NUNCA contra producción: pasa la URL del preview con -e BASE_URL=...
const BASE = __ENV.BASE_URL || "http://localhost:3000";

export const options = {
  stages: [
    { duration: "1m", target: 10 },
    { duration: "2m", target: 100 },
    { duration: "2m", target: 500 },
    { duration: "2m", target: 1000 },
    { duration: "1m", target: 0 },
  ],
  thresholds: {
    http_req_duration: ["p(95)<500"],
    http_req_failed: ["rate<0.01"],
    checks: ["rate>0.99"],
  },
};

export default function () {
  // 1. Home
  const home = http.get(BASE + "/");
  check(home, { "home 200": (r) => r.status === 200 });
  sleep(1);

  // 2. Página de evento (varía el slug para no pegar siempre al mismo cache)
  const slugs = ["evento-a", "evento-b", "evento-c"];
  const slug = slugs[Math.floor(Math.random() * slugs.length)];
  const evento = http.get(BASE + "/eventos/" + slug);
  check(evento, { "evento 200": (r) => r.status === 200 });
  sleep(2);

  // 3. Registro (datos con prefijo k6- para poder limpiar después)
  const payload = JSON.stringify({
    email: "k6-" + __VU + "-" + __ITER + "@loadtest.local",
    name: "k6 usuario " + __VU,
  });
  const reg = http.post(BASE + "/api/register", payload, {
    headers: { "Content-Type": "application/json" },
  });
  check(reg, {
    "registro no es 5xx": (r) => r.status < 500,
    "registro responde en <1s": (r) => r.timings.duration < 1000,
  });
  sleep(1);
}
Correr contra un preview
k6 run -e BASE_URL=https://tu-app-git-rama.vercel.app scripts/loadtest/k6-flujo.js
Si algún umbral falla, k6 termina con código 99 (lo comprobé en k6 v2.0): sirve tal cual como paso de CI. Con --summary-export resumen.json guardas las métricas para comparar corridas.

Arreglo: lo que k6 te devuelve es el cliff (a cuántos usuarios se dispara el p95) y, cruzado con los logs de la base y de Vercel, el recurso que se saturó: conexiones a Postgres, tiempo de función, memoria. Cada cuello de botella apunta a una de las cinco revisiones anteriores. Dos advertencias que ahorran horas:

  • Si tu endpoint tiene límite de peticiones por IP, k6 desde una sola máquina lo va a saturar. Eso no es un fallo del sistema: es la defensa funcionando. Para probar carga real distribuida hay que correr desde varios orígenes (k6 Cloud o varios runners).
  • k6 genera datos. Sin un prefijo claro (k6-, test-) no puedes limpiar después, y los dashboards muestran registros falsos durante semanas. Al terminar: delete from registrations where email like 'k6-%';
  • Corre siempre contra un preview o staging. Un preview en Vercel con la variable de la base de producción escribe en producción; si vas a probar escrituras, apunta a una base separada.

Y enterarte cuando falle de verdad

La revisión no termina en k6. Sin observabilidad, los errores en producción los descubre el usuario antes que tú, y tardan horas o días. El mínimo viable para una app Next.js en Vercel: un rastreador de errores (Sentry tiene plan gratuito) con el DSN configurado en Production y en Preview, un try/catch que reporte en los endpoints críticos, y al menos un canal de alerta que sí leas (un bot de Telegram para una persona funciona mejor que un correo). Prueba que llega disparando un error simulado. Si tienes crons o workflows externos, cada uno con su manejador de errores; un cron que falla en silencio es igual que no tener cron.

08 antes de abrir el tráfico

El checklist de doce puntos

Cuando las seis revisiones ya pasaron, esto es lo que reviso la noche anterior a abrir una campaña o un evento. Si un punto no se puede marcar con un número o un archivo concreto, no está hecho.

  • Las queries de las pantallas principales tienen índice sobre las columnas por las que filtran y ordenan (explain analyze dice Index Scan, no Seq Scan).
  • No hay queries dentro de bucles ni listas sin limit en las rutas calientes.
  • Ninguna agregación sobre toda la historia corre dentro de un request: días cerrados vienen del rollup, solo hoy en vivo.
  • El rollup se reconcilió contra el conteo directo para un rango conocido y coincidió.
  • Cada caché tiene su fila en la tabla de invalidación, y ninguna llave sirve datos de un usuario a otro.
  • Después de cada escritura crítica, lo que no puede perderse se espera y lo demás va en waitUntil o en una cola.
  • Lo que tarda minutos tiene cola con reintentos, llave de idempotencia y un lugar donde ver los fallidos.
  • Todo proceso que escribe a una API externa tiene piso de fecha, presupuesto por corrida y marca de agua.
  • Los endpoints públicos tienen límite de peticiones, y las variables de entorno existen en Preview además de Production.
  • k6 corrió contra un preview con un flujo real, y sabes a cuántos usuarios se dispara el p95 y qué recurso se saturó.
  • Después de los arreglos, k6 volvió a correr y el cliff se movió hacia arriba. Un solo resultado no cuenta.
  • El rastreador de errores recibió un error simulado y la alerta llegó a un canal que sí lees. Los datos k6- ya se borraron.

09 para copiar

El comando /escalar-app

Las seis revisiones están escritas como un comando de Claude Code que las recorre una por una, con la regla que más importa grabada en el cuerpo: mostrar hallazgos y esperar OK antes de tocar código. Guárdalo en ~/.claude/commands/escalar-app.md y adáptale el stack. Acepta un argumento para saltar directo a una revisión (/escalar-app indexing).

Cómo correrlo sin que se vuelva un desastre

  1. Abre Claude Code en la raíz del proyecto, no en una subcarpeta: necesita ver las rutas, el esquema y el cliente de base para que el mapa de riesgos sea real.
  2. Contesta la pregunta del stack en una línea y en serio. 'Next 15, Supabase con postgres.js, Vercel, sin caché ni cola' cambia todo lo que propone después.
  3. Una revisión por sesión. Índices hoy, rollups mañana. Mezclar tres cambios y medir al final te deja sin saber cuál ayudó y cuál estorbó.
  4. Antes de aceptar un cambio, pide el diff y la métrica de antes. Un CREATE INDEX o una caché sin número al lado no se aplica.
  5. Aplica tú lo que escribe a la base. El comando no tiene permiso de psql para escribir a propósito; leer la lista y ejecutarla es el momento de revisar.
~/.claude/commands/escalar-app.md
---
description: Prepara la app del proyecto actual para escalar de 10 a 1000+ usuarios. Flujo guiado: pre-flight, diagnóstico, índices, caché, async, load testing con k6, observabilidad. Usa cuando se mencione "escalar la app", "preparar para tráfico", "load testing", "k6", "esto se va a caer con tráfico", "queries lentas", "agregar índices", "cachear esto", "antes de lanzar a producción", "cuello de botella".
argument-hint: [preflight | diagnostico | indexing | caching | async | load-test | observability]
allowed-tools: Bash(git *), Bash(vercel *), Bash(k6 *), Bash(psql *)
---

# /escalar-app

Prepara la app del directorio actual para más carga. Una revisión a la vez, con OK explícito entre cada una.

## Reglas
1. Diagnóstico antes de código. Cada revisión empieza con un audit; muestra hallazgos y espera OK antes de editar.
2. Confirma el stack en una línea antes de empezar (framework, base y dónde vive, hosting, ORM, caché, cola, observabilidad).
3. Medir antes y después. Cada cambio trae una métrica (ms de query, ms de respuesta, número de queries).
4. No agregues dependencias (Redis, cola, k6, Sentry) sin pedirlas explícitamente.
5. Nunca corras pruebas de carga contra producción. Confirma la URL de preview o local antes de ejecutar k6.

Si $ARGUMENTS nombra una revisión, salta a ella asumiendo el diagnóstico previo.

## 0. Pre-flight (solo si el hosting es Vercel)
- Lee .vercel/project.json y confirma que el proyecto es el que sirve el dominio productivo.
- Corre vercel env ls: avisa de variables que están en Production y no en Preview.
- Corre vercel alias ls: si el dominio no apunta al último deploy de producción, avisa que el auto-alias está apagado.

## 1. Diagnóstico
Recorre el proyecto y entrega una tabla [riesgo · severidad · archivo · qué pasa con 100 y con 1000 usuarios · revisión que lo resuelve] sobre cinco riesgos: queries sin índice o sin paginación; cálculos repetidos sin caché; operaciones pesadas dentro del request; endpoints públicos sin auth ni límite (márcalos SEGURIDAD); falta de observabilidad. No arregles nada. Espera OK y pregunta por cuál empezar.

## 2. Índices
Lista las queries más ejecutadas, por qué columnas filtran, ordenan o unen, y cuáles no tienen índice que las cubra. Propón solo los índices que las queries reales usan, con el CREATE INDEX CONCURRENTLY exacto. Mide con EXPLAIN ANALYZE antes y después. Señala búsquedas de texto que necesiten pg_trgm.

## 3. Caché y rollups
Para cada agregado que se calcula sobre toda la historia, propón un rollup diario (tabla por día + función de refresco desde el cron + solo hoy en vivo). Para lo que se consulta repetido, entrega la tabla [qué · dónde · TTL · llave · cuándo se invalida] antes de escribir código. Nunca datos de un usuario sin el usuario en la llave.

## 4. Async
Clasifica lo que corre después de cada escritura en crítico (se espera) y best-effort (waitUntil de @vercel/functions en serverless; nunca after() para escrituras que no pueden perderse). Lo que tarda minutos va a una cola con reintentos e idempotencia. Confirma la cola antes de agregarla.

## 5. Load testing
Genera scripts/loadtest/k6-flujo.js con el flujo real de un usuario, escalones de 10, 100, 500 y 1000 usuarios, umbrales p(95)<500ms y errores <1%, y datos con prefijo k6- para limpiar. Genera scripts/validate-deploy.sh que pegue al preview (GET /, páginas principales, POST públicos con payload mínimo) y verifique que no hay 5xx. Corre validate-deploy antes de k6. Reporta el cliff y el recurso saturado.

## 6. Observabilidad
Audita: rastreo de errores, logs estructurados, alertas, métricas de latencia, manejadores de error en crons y workflows. Propón el mínimo viable y verifica con un error simulado que la alerta llega.

## Cierre
Resumen por revisión con métricas antes/después, pendientes, y limpieza de datos de prueba (delete ... where ... like 'k6-%').

Dos decisiones de diseño del comando que no son obvias. La primera: allowed-tools pre-aprueba git, vercel, k6 y psql para que la revisión no te pida permiso en cada comando de lectura, pero no incluye nada que escriba a la base; los CREATE INDEX los aplicas tú después de ver la lista. La segunda: el argumento permite saltar a una revisión, pero el cuerpo dice 'asumiendo el diagnóstico previo'. Sin el mapa de riesgos, saltar a índices es optimizar a ciegas.

FAQ lo que suelen preguntar

Preguntas frecuentes

¿En qué orden aplico las seis revisiones si tengo poco tiempo?

Índices primero: se aplican con SQL, sin deploy, y casi siempre son la ganancia más grande. Después rollups si tienes dashboards sobre historia. Caché y async solo con evidencia del diagnóstico. k6 y observabilidad son las que nadie hace y las que te avisan de lo que no viste; si vas a saltarte algo, que no sea la alerta de errores.

¿Cuántos índices son demasiados?

Cada índice se actualiza en cada INSERT y UPDATE de la tabla. Una tabla con diez índices escribe notablemente más lento que con tres. La regla: un índice por combinación de columnas que una query real usa para filtrar u ordenar, y ninguno 'por si acaso'. Si pg_stat_user_indexes muestra un índice con idx_scan en cero después de semanas en producción, se borra.

¿Por qué no usar after() de Next.js si está en la documentación?

Porque en serverless la instancia puede congelarse después de responder. En pruebas bajo carga vi inserts dentro de after() que nunca llegaron a la base, con 200 OK al usuario. after() sirve para invalidar caché o métricas que pueden perderse. Para todo lo que no puede perderse, waitUntil de @vercel/functions o una cola.

¿Qué pasa si k6 falla desde el primer escalón?

Primero verifica que el preview responde a mano (por eso el comando genera validate-deploy.sh y lo corre antes). El caso más común no es carga: es que el preview no tiene las variables de entorno de producción y responde 500 a todo. El segundo más común es el límite de peticiones por IP haciendo su trabajo.

¿El rollup diario sirve para métricas que cambian retroactivamente, como reembolsos?

Sí, por eso la función de refresco recalcula los últimos N días en cada corrida (4 en el ejemplo) en lugar de solo hoy. Si tu negocio tiene cambios más tardíos (reembolsos a 30 días), sube N o agrega un refresco semanal más amplio. Y reconcilia contra el conteo directo antes de confiar en los números.

¿Cómo sé si el cuello de botella es la base o la función?

Con dos relojes. Mide el tiempo de la query en la base (explain analyze o mean_exec_time de pg_stat_statements) y el tiempo total del endpoint (logs de Vercel o el timings.duration de k6). Si la query tarda 50 ms y el endpoint 2 segundos, el problema está en la función: llamadas en serie, APIs externas, serialización. Si la query tarda 1.8 s, está en la base.

¿Cuántos usuarios va a aguantar mi app después de esto?

No lo sé, y desconfía de quien te dé un número sin haberla probado. La única respuesta honesta sale de k6: el escalón donde el p95 se dispara es tu capacidad real hoy. Lo que sí te puedo decir es que la diferencia entre antes y después de los índices y los rollups suele ser de un orden de magnitud, y que el segundo cliff casi siempre son las conexiones a la base, no el código.

¿Esto se hace antes de construir la app o después?

Después de que funciona y antes de que reciba tráfico real. Antes no tienes queries reales que medir, y optimizar sobre suposiciones es como agregar índices por si acaso. El momento ideal es cuando ya hay datos de prueba en volumen (cientos de miles de filas, no diez) y una fecha de lanzamiento en el calendario. Y se repite cada vez que la tabla principal cambia de orden de magnitud.

¿Sirve si no uso Next.js, Supabase ni Vercel?

Las seis revisiones sí; los comandos no. Las queries de pg_stat_user_tables y pg_stat_statements funcionan en cualquier Postgres. El rollup diario es SQL puro. Lo que cambia es el equivalente de unstable_cache (Redis en un servidor propio), de waitUntil (en un VPS con Node simplemente no cierras el proceso, y la cola es BullMQ) y de los límites de Vercel. Si tu base es MySQL o SQLite, el diagnóstico de índices cambia de vista pero la lógica es la misma: encontrar qué se lee sin índice.

¿Tengo que programar yo o lo hace Claude Code?

Claude Code hace el trabajo de leer el proyecto, proponer índices, escribir el script de k6 y los cambios de caché y async. Lo que no debe hacer solo es aplicar cosas a la base ni decidir qué cachear sin la tabla de invalidación. Tu trabajo es leer la tabla de riesgos, aprobar una revisión a la vez, ejecutar el SQL que escribe, y mirar los números de antes y después. Si no entiendes un cambio, pídele que lo explique antes de aceptarlo; eso no es lentitud, es la parte que evita el bug.

Cierre de la guía

Ninguna de las seis revisiones es sofisticada. Lo que las hace funcionar es el orden y la disciplina de medir antes de tocar: la query de seq_scan antes del índice, el explain analyze antes y después, la tabla de invalidación antes de la caché, el preview antes de k6. El día que una campaña meta mil personas de golpe a tu formulario, la diferencia entre 'aguantó' y 'la tiramos' va a ser una de estas seis cosas, y casi siempre la primera. Esta guía vive en el Lab de David Iriza.

Fuentes oficiales8

Sigue con estas guías

Guía escrita con la información oficial disponible al 28 de agosto de 2026. Esta página no está afiliada a Vercel. Las herramientas cambian; ante la duda, revisa la documentación oficial de Vercel Functions.