Redis sobre HTTP, facturado por petición, sin pool de conexiones que cuidar
Rate limits de ventana deslizante en el edge, tokens de idempotencia que sobreviven a los reintentos, lecturas cache-aside con stale-while-revalidate. Conectamos Upstash dentro de la aplicación para que la historia operativa coincida con el deploy serverless: sin conexiones que gestionar, sin capacidad provisionada que planificar, sin PagerDuty para Redis.
Por qué este stack
La API HTTP significa que funciona en el edge
El Redis tradicional necesita una conexión TCP. Las funciones serverless y los runtimes edge no mantienen conexiones TCP de forma limpia. Upstash expone Redis sobre HTTP/REST, así que el mismo código que corre en Node también corre en edge de Cloudflare o Vercel sin cambios.
Pagas por petición, no por capacidad provisionada
Una instancia Redis normal factura por tamaño de nodo y corre la uses o no. Upstash factura por número de comandos. Para workloads en ráfaga (rate limiting, idempotencia, lookup de sesión) es la forma correcta. Modelamos el coste por adelantado basándonos en patrones de petición reales.
Librería de rate limiting incorporada
`@upstash/ratelimit` viene con sliding-window, token-bucket y fixed-window, todos respaldados por MULTI/EXEC de Redis. La implementación es correcta bajo concurrencia y la API es una línea en el call site.
Replicación global cuando hace falta
Las bases globales de Upstash replican las escrituras entre regiones y sirven las lecturas desde la réplica más cercana. Para un deploy multi-región que necesita la misma vista de rate limit y sesión en todas partes, esto colapsa todo un problema de infraestructura en un flag de config.
La ausencia de gestión de conexiones es una feature
HTTP es stateless. Sin pool de conexiones que dimensionar, sin paso de warm-up al cold start, sin conexión filtrada que despierta a alguien a las 3 de la madrugada. La función serverless hace una petición HTTP y o recibe un resultado o un error. Esa es toda la superficie operativa.
Qué construimos con esta tecnología
Rate limiting por IP, por usuario, por tenant
Sliding-window o token-bucket por caso, claves distintas por dimensión, fail-open o fail-closed configurados por ruta, headers de respuesta para X-RateLimit-Remaining.
Session storage con TTL
Sesiones basadas en cookie almacenadas en Redis con TTL explícito, rotación al cambio de privilegio, revocación en servidor que se propaga en milisegundos.
Cache-aside para consultas lentas
Un helper tipado `getOrSet` que lee desde Redis, recurre al origen, escribe de vuelta con TTL, soporta stale-while-revalidate para resiliencia.
Tokens de idempotencia
Una clave única por petición lógica, registrada junto a la respuesta, repetida tal cual en el reintento. Combinada con el patrón de webhook de Stripe, es la capa de dedupe production-grade.
Patrones de Edge Config
Feature flags, allowlists, kill switches leídos desde Redis en el edge en milisegundos de un dígito. Sin servicio central que llamar, sin JWT que validar.
Colas de jobs cortos
Colas basadas en list para trabajo corto (envío de email, transformación de imagen, reindex de búsqueda) con un worker que hace poll o un webhook que drena. Las colas pesadas se quedan en un sistema de colas de verdad.
Counters para analytics
Counters atómicos por tenant, por ruta, por métrica. Agregados en buckets temporales, vaciados periódicamente al warehouse de analytics.
Primitiva de lock distribuido
Locks basados en SETNX con fencing tokens para secciones críticas que no pueden correr en concurrencia entre invocaciones de función.
Setup de replicación multi-región
Base de datos global configurada, regiones de lectura seleccionadas, región de escritura documentada, comportamiento de fallback explicitado en el runbook.
Monitoreo de coste + alertas
Volumen de consultas diario frente a la cuota, alertas al 80 por ciento del presupuesto, informes de tendencia mensuales conectados al dashboard de administración.
Migración desde self-hosted o ElastiCache
Keyspace existente exportado, repetido sobre Upstash, config aplicativa cambiada, clúster antiguo deprecado con ventana de rollback.
Upstash Workflow para ejecución durable
Workflows long-running retry-safe definidos como código y orquestados por Upstash. Usado donde un job en background tiene varios pasos que deben completar todos eventualmente.
Rate limiting de ventana deslizante e idempotencia en una sola Server Action
Un helper rate-limita la llamada por IP y usuario; otro lee o escribe el registro de idempotencia. La Server Action ejecuta ambos antes de tocar la base de datos, así un POST reintentado pega al limiter una sola vez y la escritura duplicada nunca ocurre.
La mayoría de guías "Upstash quickstart" te muestra un solo GET y SET. El patrón que le gana a Upstash su sitio en un stack serverless es el de abajo: un rate limiter de ventana deslizante y un check de token de idempotencia, los dos corriendo dentro de una Server Action, los dos respaldados por la misma instancia Redis, con el patrón de webhook Stripe visto antes en el stack tomando prestada la misma primitiva de idempotencia.
1. El rate limiter
@upstash/ratelimit hace la corrección algorítmica dentro de Redis con MULTI/EXEC. El código aplicativo es una línea en el call site.
// src/lib/upstash/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
export const ipLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '10 s'),
prefix: 'rl:ip',
analytics: true,
})
export const userLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '60 s'),
prefix: 'rl:user',
analytics: true,
})
export const tenantLimiter = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(1000, '60 s', 1000),
prefix: 'rl:tenant',
analytics: true,
})
export async function checkRate(opts: {
ip: string
userId: string
tenantId: string
}): Promise<
| { ok: true }
| { ok: false; scope: 'ip' | 'user' | 'tenant'; reset: number }
> {
const ipResult = await ipLimiter.limit(opts.ip)
if (!ipResult.success) return { ok: false, scope: 'ip', reset: ipResult.reset }
const userResult = await userLimiter.limit(opts.userId)
if (!userResult.success) return { ok: false, scope: 'user', reset: userResult.reset }
const tenantResult = await tenantLimiter.limit(opts.tenantId)
if (!tenantResult.success) return { ok: false, scope: 'tenant', reset: tenantResult.reset }
return { ok: true }
}
2. El helper de idempotencia
Una clave única codifica la forma de la petición; la respuesta se cachea contra esa clave. Un reintento con la misma clave devuelve la respuesta original sin volver a ejecutar el trabajo.
// src/lib/upstash/idempotency.ts
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
const TTL_SECONDS = 24 * 60 * 60
interface RecordedResult<T> {
status: 'success' | 'error'
response: T
}
export async function withIdempotency<T>(
key: string,
run: () => Promise<T>,
): Promise<T> {
const fullKey = `idem:${key}`
const existing = await redis.get<RecordedResult<T>>(fullKey)
if (existing) {
if (existing.status === 'error') {
throw new Error('previous attempt failed; retry with a different key')
}
return existing.response
}
try {
const result = await run()
await redis.set(
fullKey,
{ status: 'success', response: result } satisfies RecordedResult<T>,
{ ex: TTL_SECONDS },
)
return result
} catch (err) {
await redis.set(
fullKey,
{ status: 'error', response: null } satisfies RecordedResult<unknown>,
{ ex: 60 },
)
throw err
}
}
3. La Server Action que usa los dos
La Server Action toma un header Idempotency-Key, ejecuta el rate limiter, luego ejecuta la lógica de negocio dentro del helper de idempotencia. Un POST reintentado con la misma clave devuelve la respuesta original; una escritura duplicada nunca ocurre.
// app/[lang]/(app)/invoices/actions.ts
'use server'
import { headers } from 'next/headers'
import { z } from 'zod'
import { checkRate } from '@/lib/upstash/ratelimit'
import { withIdempotency } from '@/lib/upstash/idempotency'
import { getServerSession } from '@/lib/auth/server'
import { stripe } from '@/lib/stripe/server'
const Input = z.object({
customerId: z.string(),
amountCents: z.number().int().positive(),
description: z.string().min(1).max(200),
})
export async function createInvoice(
raw: unknown,
): Promise<
| { ok: true; invoiceId: string }
| { ok: false; error: string; reset?: number }
> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const hdrs = await headers()
const ip = hdrs.get('x-forwarded-for')?.split(',')[0] ?? '0.0.0.0'
const idempotencyKey = hdrs.get('idempotency-key')
if (!idempotencyKey) return { ok: false, error: 'missing idempotency key' }
const rate = await checkRate({
ip,
userId: session.userId,
tenantId: session.tenantId,
})
if (!rate.ok) {
return { ok: false, error: `rate limited (${rate.scope})`, reset: rate.reset }
}
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
return withIdempotency(`invoice:${session.tenantId}:${idempotencyKey}`, async () => {
const invoice = await stripe.invoices.create({
customer: parsed.data.customerId,
collection_method: 'send_invoice',
days_until_due: 30,
description: parsed.data.description,
metadata: { tenant_id: session.tenantId },
})
await stripe.invoiceItems.create({
customer: parsed.data.customerId,
amount: parsed.data.amountCents,
currency: 'eur',
invoice: invoice.id,
})
return { ok: true as const, invoiceId: invoice.id }
})
}
4. Qué te compra esto
El rate limiter corre en un par de milisegundos en el edge. El helper de idempotencia convierte un POST reintentado en una lookup de cache, así la API de Stripe nunca ve un duplicado. Todo el flujo vive en tres archivos; la capa Redis no aparece en la lógica aplicativa salvo como dos helpers que se leen como funciones normales.
Este es el Upstash que se gana su sitio: no una curiosidad "hemos sustituido Redis por HTTP", sino la capa operativa que hace los deploys serverless realmente seguros bajo reintentos y tráfico alto.
Preguntas frecuentes
¿Upstash frente a ElastiCache o Memorystore?
Upstash para deploys serverless, runtimes edge y workloads en ráfaga donde el pricing pay-per-request es la forma correcta. ElastiCache o Memorystore cuando tienes un servicio Node long-running, un equipo de infra interno y un workload donde la capacidad provisionada le gana a la facturación por petición.
HTTP frente a TCP — ¿cuánta latencia extra?
Milisegundos de un dígito desde una función Vercel o Cloudflare a la región Upstash más cercana. Para el caso común de rate-limit y lookup de sesión, es más rápido que el cold-start de abrir una nueva conexión TCP. Medimos la latencia real en staging.
¿La latencia en edge aguanta la comparación con un Redis regional?
Para las lecturas desde la base global, sí — la lectura pega a la réplica más cercana. Para las escrituras que tienen que hacer round-trip a la región primaria, aceptas la latencia inter-región. Documentamos el split lectura-vs-escritura por caso de uso.
¿Cómo escala el pricing a volumen alto de peticiones?
Linealmente. Upstash factura por comando; tráfico alto significa factura más grande, predecible según la curva. Para volúmenes muy altos un plan Pro con capacidad reservada compensa; lo modelamos en la fase de scoping basándonos en RPS esperados.
¿Podemos correr scripts Lua sobre Upstash?
Sí. `EVAL` y `EVALSHA` funcionan, y las operaciones atómicas complejas (las primitivas de rate-limit mismas) viajan como scripts dentro de las librerías oficiales. Los scripts custom están soportados para nuestra lógica aplicativa donde importa el número de round-trips.
¿Y las garantías de persistencia?
Upstash Redis persiste las escrituras de forma durable con replicación. Una read-after-write estándar es consistente dentro de la región. Entre regiones, la base global es eventualmente consistente con lag de replicación en milisegundos bajos. Elegimos el modelo de consistencia por caso de uso y lo documentamos.
¿Cómo funciona la migración desde Redis self-hosted?
Dos caminos. Para keyspaces pequeños, dump y replay. Para los más grandes, dual-write durante una ventana (la aplicación escribe a ambos, lee del viejo hasta que el catch-up esté verificado), luego cutover. El runbook deja abierto el camino del rollback hasta que firmes.
Cuéntanos qué estás cacheando, rate-limitando o coordinando
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Integración Upstash en una semana.