Tu startup acaba de cerrar una ronda seed. Cuentas con 18 meses de runway. Tu MVP en Supabase funciona de maravilla... hasta que abres el correo y encuentras una factura de $4,200 por un solo mes. ¿Por qué? Tu dashboard de ventas está haciendo 2.6 millones de consultas diarias. Nadie ha tocado nada, simplemente dejaron la pestaña abierta.
Photo: Deng Xiang on Unsplash
Este tipo de situaciones son más comunes de lo que crees en startups. No es un problema de escalabilidad, sino de polling descontrolado. La arquitectura real-time de Supabase invita a suscribirse a cambios, pero muchos founders siguen consultando la base de datos cada segundo, "porque así lo hacían con Firebase". La gran diferencia es que Firebase cobra por GB transferido, mientras que Supabase cobra por operaciones de lectura. Un setInterval(fetchData, 1000) es una verdadera bomba de tiempo.
El modelo de costos de Supabase que nadie lee hasta que es tarde
Supabase opera con Postgres. Cada query es una consulta de lectura que se factura. El plan gratuito incluye 500MB de base de datos y 2GB de transferencia, pero el verdadero límite es 50,000 consultas mensuales. Suena generoso, sin embargo, el diablo está en los detalles.
Un dashboard que hace polling cada segundo ejecuta 86,400 consultas diarias. Con solo tres usuarios que dejen la pestaña abierta durante 8 horas de trabajo, superas el límite gratuito en tan solo dos días.
El plan Pro cuesta $25/mes, que incluye 5 millones de consultas. Podría parecer suficiente, pero en mi experiencia, no lo es. Una aplicación SaaS típica con 500 usuarios activos, dashboards en tiempo real y sincronización automática puede ejecutar 15-20 millones de consultas mensuales sin muchos problemas. Cada millón adicional cuesta $2.50. Los $4,200 que mencioné antes no son un caso hipotético: una startup de tecnología logística con 30 empleados usando dashboards en vivo llegó a esa cifra en marzo de 2026.
La trampa del "tiempo real" mal implementado
Supabase Realtime está diseñado para trabajar con suscripciones websocket persistentes. Esto te permite detectar cambios sin hacer consultas constantes. Sin embargo, la documentación presenta un problema: los ejemplos iniciales muestran supabase.from('table').select() sin explicar cuándo y cómo utilizarlo correctamente.
Muchos desarrolladores copian este patrón y lo envuelven en useEffect con React:
useEffect(() => {
const interval = setInterval(async () => {
const { data } = await supabase
.from('orders')
.select('*')
.eq('status', 'pending');
setOrders(data);
}, 1000);
return () => clearInterval(interval);
}, []);
Este código funciona, pero honestamente, también arruina tu presupuesto. 86,400 consultas diarias por componente. Si renderizas este componente en tres páginas diferentes de tu panel de administración, terminan siendo 259,200 consultas diarias. Al mes: 7.7 millones de consultas. Costo: $67.50 solo por este componente.
La arquitectura correcta usa Realtime:
useEffect(() => {
const channel = supabase
.channel('orders-channel')
.on('postgres_changes', { event: '*', schema: 'public', table: 'orders', filter: 'status=eq.pending' }, (payload) => {
setOrders(prev => [...prev, payload.new]);
})
.subscribe();
return () => supabase.removeChannel(channel);
}, []);
Una sola suscripción websocket. Cero consultas recurrentes. Solo pagas por las actualizaciones reales. Si tu tabla orders recibe 1,000 nuevos pedidos diarios, son 1,000 eventos, no 86,400 queries.
El problema invisible: las consultas N+1 en tu middleware
Photo: Luke Chesser on Unsplash
Polling es evidente, pero lo curioso es que hay un asesino silencioso: las consultas N+1 en tu lógica de negocio. Sucede al iterar sobre resultados y ejecutar una consulta por cada ítem.
Este patrón aparece frecuentemente en aplicaciones con relaciones complejas:
// ❌ Consultas N+1
const users = await supabase.from('users').select('id, name');
for (const user of users.data) {
const { data: orders } = await supabase
.from('orders')
.select('*')
.eq('user_id', user.id);
user.orders = orders;
}
Si tienes 100 usuarios, ejecutas 101 consultas (1 para usuarios + 100 para órdenes). Con 1,000 usuarios, son 1,001 consultas cada vez que cargas este endpoint. Si tu dashboard lo consulta cada 30 segundos, ejecutas 2.8 millones de consultas mensuales solo aquí.
Supabase soporta joins nativos en Postgres. La solución correcta usa una sola query:
// ✅ Una sola consulta
const { data: users } = await supabase
.from('users')
.select(`
id,
name,
orders (
id,
total,
status
)
`);
Una query. Resultado idéntico. La diferencia en costos es significativa: de 1,001 consultas a 1 consulta. Para 1,000 usuarios consultados 100 veces al día, pasas de 10 millones de consultas mensuales ($12.50 en el plan Pro) a 3,000 consultas mensuales (incluidas en el plan base).
La ilusión del ORM: cuando PostgREST no es tu amigo
Supabase utiliza PostgREST para convertir tu base de datos en una API. Es genial para prototipado, pero peligroso en producción sin comprender los límites.
Un caso real: una fintech en Colombia tenía un sistema que verificaba cambios en transacciones cada 5 segundos. Usaron este patrón:
const checkChanges = async () => {
const { data } = await supabase
.from('transactions')
.select('*')
.gte('updated_at', lastCheck.toISOString());
if (data.length > 0) {
sendNotifications(data);
}
};
setInterval(checkChanges, 5000);
Corrió en producción durante dos semanas y nadie notó problemas hasta que llegó una factura de 12 millones de consultas. El sistema consultaba la tabla completa cada 5 segundos, filtrando en el cliente. Con 50,000 transacciones, cada consulta escaneaba toda la data.
El error: no usaron índices y consultaban sin límites. La solución fue sencilla:
- Crear índice en
updated_at - Cambiar a Realtime triggers
CREATE INDEX idx_transactions_updated
ON transactions(updated_at DESC);
Y reemplazar polling con un trigger:
const channel = supabase
.channel('transaction-changes')
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'transactions' }, (payload) => sendNotifications([payload.new]))
.subscribe();
Costo final: de $35/mes en consultas extras a prácticamente $0. Las notificaciones ahora llegan en menos de 200ms en lugar de hasta 5 segundos.
Row Level Security: cuando la seguridad dispara tus queries
RLS (Row Level Security) es uno de los grandes atractivos de Supabase. Define políticas de acceso directamente en Postgres. Pero ojo, cada política ejecuta una subquery. Si diseñas mal tus políticas, podrías triplicar tus consultas sin darte cuenta.
Un ejemplo de política mal optimizada:
CREATE POLICY "Users can read own orders"
ON orders FOR SELECT
USING (
user_id IN (
SELECT id FROM users
WHERE auth_id = auth.uid()
)
);
Cada vez que consultas orders, Postgres ejecuta esa subquery. Si tienes 10 políticas apiladas en una tabla, cada SELECT ejecuta 10 subqueries adicionales. Tu dashboard que carga órdenes ejecuta en realidad 11 consultas por cada carga.
La versión optimizada utiliza joins directos y cachea relaciones:
CREATE POLICY "Users can read own orders"
ON orders FOR SELECT
USING (auth.uid() = user_id);
Eliminas la subquery. Postgres puede usar índices directamente. Para una aplicación con 500 usuarios activos consultando su historial, la diferencia es pasar de 5.5 millones de consultas mensuales a 500,000. Ahorro: $10/mes que va sumando a medida que la escala crece.
El problema de los JOINs anidados en políticas RLS
Las cosas se complican con relaciones de muchos a muchos. Supongamos que tienes equipos, usuarios en equipos y proyectos compartidos:
-- ❌ Política costosa
CREATE POLICY "Users can read team projects"
ON projects FOR SELECT
USING (
id IN (
SELECT project_id
FROM team_projects
WHERE team_id IN (
SELECT team_id
FROM team_members
WHERE user_id = auth.uid()
)
)
);
Tres niveles de subqueries. Cada consulta a projects ejecuta estas tres queries anidadas. Con 1,000 proyectos y 50 usuarios consultando, son 150,000 subqueries adicionales por día.
Una solución es desnormalizar parcialmente o usar vistas materializadas:
-- Crea una vista materializada
CREATE MATERIALIZED VIEW user_accessible_projects AS
SELECT
tm.user_id,
tp.project_id
FROM team_members tm
JOIN team_projects tp ON tm.team_id = tp.team_id;
-- Política simplificada
CREATE POLICY "Users can read accessible projects"
ON projects FOR SELECT
USING (
id IN (
SELECT project_id
FROM user_accessible_projects
WHERE user_id = auth.uid()
)
);
-- Refresca la vista cada hora
SELECT cron.schedule(
'refresh-user-projects',
'0 * * * *',
$$REFRESH MATERIALIZED VIEW user_accessible_projects$$
);
Las vistas materializadas tienen un costo porque necesitas refrescarlas. Sin embargo, para datos que cambian cada hora (no cada segundo), reduces consultas de millones a miles. Una startup de project management en México aplicó esto y redujo sus consultas en un 89%.
Paginación: el detalle de $800 mensuales
La mayoría de los tutoriales de Supabase muestran consultas básicas sin paginación. Desarrolladores nuevos cargan tablas completas:
const { data } = await supabase
.from('products')
.select('*');
Si tu tabla products tiene 10,000 items, cada carga transfiere 10,000 registros. Esto puede no parecer tan malo al principio, pero imagina que en tu panel de administración se recarga cada vez que cambias de pestaña. Un usuario activo puede disparar 50 cargas diarias. Con 20 usuarios internos: 1 millón de registros transferidos al mes.
Supabase cobra por operaciones de lectura y también por egress (transferencia de datos). El plan Pro incluye 50GB. Con registros promedio de 2KB, esos 10,000 productos son 20MB por carga. 50 cargas diarias × 20 usuarios × 30 días = 600GB mensuales. Excedes tu límite por 550GB. Costo adicional: $55/mes solo en transferencia.
La paginación correcta usa range:
const PAGE_SIZE = 50;
const { data, count } = await supabase
.from('products')
.select('*', { count: 'exact' })
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
Cargas 50 registros (100KB) en lugar de 10,000 (20MB). Reduces transferencia en un impresionante 99%. El ahorro escala exponencialmente con el tamaño de tu tabla.
Infinite scroll: el patrón que engaña
Muchos implementan infinite scroll así:
const loadMore = async () => {
const { data } = await supabase
.from('posts')
.select('*')
.range(posts.length, posts.length + 20);
setPosts([...posts, ...data]);
};
Aparentemente eficiente, pero el engaño está en cómo Postgres ejecuta range. Internamente, Postgres escanea desde el inicio hasta tu offset, descartando filas. Si estás en la página 100 (offset 2,000), Postgres lee 2,000 filas y descarta 1,980.
Con 100 usuarios scrolleando hasta la página 50, estás escaneando 100,000 filas diarias cuando solo muestras 2,000. La solución es usar paginación basada en cursores:
const loadMore = async () => {
const lastPost = posts[posts.length - 1];
const { data } = await supabase
.from('posts')
.select('*')
.lt('created_at', lastPost.created_at)
.order('created_at', { ascending: false })
.limit(20);
setPosts([...posts, ...data]);
};
Postgres utiliza el índice en created_at. No escanea filas anteriores. Consultas constantes sin importar la profundidad del scroll.
Monitoring: descubre el desastre antes de la factura
Supabase no ofrece un análisis detallado de queries en su dashboard. Ves consultas totales, pero no qué endpoints están causando problemas. Para 2026, el tooling mejoró, pero necesitas configuración manual.
La solución es utilizar Postgres mismo. Activa pg_stat_statements:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Consulta las queries más costosas
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Esto muestra exactamente qué queries consumen más recursos. Una startup de e-learning descubrió que su query "usuarios activos últimos 30 días" ejecutaba 400,000 veces al mes y tomaba 3 segundos cada vez. La query escaneaba toda la tabla user_sessions.
La optimización fue simple: agregar un índice y una columna calculada:
-- Índice en sesiones
CREATE INDEX idx_sessions_recent
ON user_sessions(created_at)
WHERE created_at > NOW() - INTERVAL '30 days';
-- Columna calculada en users
ALTER TABLE users
ADD COLUMN last_active_at TIMESTAMP;
-- Trigger para actualizar
CREATE TRIGGER update_last_active
AFTER INSERT ON user_sessions
FOR EACH ROW
EXECUTE FUNCTION update_user_last_active();
Resultado: query de 3 segundos a 40ms. Reducción de carga en Postgres del 76%. Factura mensual de $180 a $42.
Alertas automáticas con Supabase Hooks
Configura webhooks que te alerten cuando algo va mal. Edge Functions pueden monitorear tus métricas:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL'),
Deno.env.get('SUPABASE_SERVICE_KEY')
);
// Consulta métricas de stats
const { data } = await supabase.rpc('get_query_stats');
const queriesLastHour = data.filter(d =>
d.timestamp > Date.now() - 3600000
).reduce((sum, d) => sum + d.calls, 0);
// Alerta si superas threshold
if (queriesLastHour > 100000) {
await fetch(Deno.env.get('SLACK_WEBHOOK'), {
method: 'POST',
body: JSON.stringify({
text: `⚠️ Query spike: ${queriesLastHour} queries in the last hour`
})
});
}
return new Response('OK');
});
Programas esta función con pg_cron cada hora. Si tus queries se disparan, recibes la alerta en Slack antes de que el problema se salga de control.
El costo oculto de las funciones RPC personalizadas
Supabase permite crear funciones Postgres expuestas como endpoints RPC. Es poderoso, pero peligroso si no optimizas adecuadamente.
Una función común es calcular estadísticas agregadas.
CREATE OR REPLACE FUNCTION get_dashboard_stats()
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'total_users', (SELECT COUNT(*) FROM users),
'active_users', (SELECT COUNT(*) FROM users WHERE last_active > NOW() - INTERVAL '7 days'),
'total_orders', (SELECT COUNT(*) FROM orders),
'revenue', (SELECT SUM(total) FROM orders WHERE status = 'completed')
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
Cada llamada ejecuta 4 queries de agregación. Si tu dashboard llama esto cada vez que cargas la página principal, con 50 usuarios internos consultando 20 veces al día, son 4,000 queries diarias solo de esta función. En un mes: 120,000 queries.
La optimización es usar tablas de caché actualizadas por triggers:
-- Tabla de stats cacheadas
CREATE TABLE dashboard_stats_cache (
id INT PRIMARY KEY DEFAULT 1,
total_users INT,
active_users INT,
total_orders INT,
revenue DECIMAL,
updated_at TIMESTAMP DEFAULT NOW()
);
-- Función que actualiza cache
CREATE OR REPLACE FUNCTION refresh_dashboard_stats()
RETURNS VOID AS $$
BEGIN
INSERT INTO dashboard_stats_cache VALUES (
1,
(SELECT COUNT(*) FROM users),
(SELECT COUNT(*) FROM users WHERE last_active > NOW() - INTERVAL '7 days'),
(SELECT COUNT(*) FROM orders),
(SELECT SUM(total) FROM orders WHERE status = 'completed'),
NOW()
)
ON CONFLICT (id) DO UPDATE SET
total_users = EXCLUDED.total_users,
active_users = EXCLUDED.active_users,
total_orders = EXCLUDED.total_orders,
revenue = EXCLUDED.revenue,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- Actualiza cada 5 minutos
SELECT cron.schedule(
'refresh-stats',
'*/5 * * * *',
$$SELECT refresh_dashboard_stats()$$
);
-- Función RPC simplificada
CREATE OR REPLACE FUNCTION get_dashboard_stats()
RETURNS JSON AS $$
BEGIN
RETURN (SELECT row_to_json(dashboard_stats_cache) FROM dashboard_stats_cache WHERE id = 1);
END;
$$ LANGUAGE plpgsql;
Ahora cada llamada es un simple SELECT a una tabla de una fila. De 4 queries agregadas a 1 query indexada. Reducción del 95% en tiempo de ejecución y carga en la base.
Para cerrar: la factura de Supabase es el feedback que tu arquitectura necesita
Los costos de Supabase no son un error, sino una oportunidad. Te obligan a diseñar de manera más eficiente. Cada dólar extra en tu factura señala un problema arquitectónico: polling donde deberías usar websockets, N+1 queries cuando deberías hacer joins, y scans completos donde necesitas índices.
Muchas startups descubren esto demasiado tarde. Prefieren pagar $200 mensuales adicionales antes que invertir dos días optimizando queries. Esto funciona hasta que escalan. Cuando tienes 10,000 usuarios activos, esos $200 se convierten en $2,000. Y el problema es 10 veces más difícil de resolver con tráfico en producción.
La mentalidad correcta: trata cada query como si costara $1 por ejecución. ¿Realmente necesitas consultar esta tabla cada segundo? ¿Podrías cachear este resultado? ¿Debería ser un websocket en lugar de polling? Las respuestas honestas te ahorrarán miles de dólares.
Entonces, ¿cuántas queries está ejecutando tu aplicación ahora mismo? ¿Lo sabes sin abrir el dashboard de Supabase?