Llevamos cuatro años usando AWS Lambda en nuestro equipo. Somos tres personas — cuatro cuando hay presupuesto para contratar — y construimos el backend de una plataforma SaaS B2B. Lambda nos funcionó bien desde el principio: deploy predecible, escala automática, costo proporcional al uso. No teníamos razones reales para cuestionarlo.
Pero hace unos ocho meses empecé a ver demasiados complaints sobre latencia en los logs de nuestros clientes europeos. La arquitectura era estándar: API Gateway + Lambda en us-east-1, con CloudFront delante. Funcional, pero la experiencia para usuarios fuera de EEUU era notoria en los P95. Fue ahí cuando empecé a investigar Workers más en serio.
Terminé corriendo los dos sistemas en paralelo durante cinco meses, con los mismos endpoints, las mismas métricas y la misma carga real. Acá está lo que aprendí.
V8 Isolates vs Contenedores: La Diferencia que Determina Todo lo Demás
La primera vez que intenté explicarle Workers a un compañero, empecé por el marketing: “es serverless en el edge, latencia ultrabaja, red global…” y los ojos se le pusieron vidriosos. La segunda vez empecé por la arquitectura, y ahí sí prestó atención.
Lambda funciona con micro-VMs. AWS usa Firecracker internamente — una tecnología muy buena, de hecho — que levanta entornos virtualizados con su propio kernel Linux liviano. Cuando una función estuvo inactiva unos minutos, esa VM se destruye. La próxima invocación tiene que arrancar el kernel, inicializar el runtime de Node.js, cargar tus módulos, ejecutar el código de inicialización. Eso es el cold start. No es un bug, es la consecuencia natural del modelo.
Cloudflare Workers usa V8 isolates. El proceso de V8 ya está corriendo en el servidor de Cloudflare. Crear un nuevo isolate — el contexto de ejecución aislado para tu Worker — toma microsegundos porque no hay kernel que arrancar. Es conceptualmente más parecido a crear un nuevo web worker en el browser que a levantar una VM. El overhead es de otro orden de magnitud.
Esto explica los cold starts casi inexistentes. Pero también explica las restricciones: vives dentro del sandbox de V8, sin acceso al sistema operativo real. Nada de módulos nativos (archivos .node), nada de filesystem local, nada de sockets TCP directos. El runtime es básicamente el mismo que en el browser, con las APIs de Service Workers más algunas extensiones de Cloudflare.
Una cosa que no anticipé — y me quemó una vez — es el comportamiento de la memoria entre requests. En Lambda, cada instancia caliente conserva su estado en memoria mientras no se destruya. En Workers, cada isolate es teóricamente efímero, aunque Cloudflare reutiliza isolates para requests consecutivos del mismo Worker (lo documentan como “isolate reuse”). El comportamiento exacto no está completamente especificado. Tuve un bug sutil con un array global que se estaba acumulando entre requests en staging, hasta que lo noté en los logs. Culpa mía por usar estado global mutable — pero en Lambda ese patrón era más predecible.
wrangler dev es genuinamente bueno: corre un isolate local que se comporta casi idéntico a producción. Con Lambda, el desarrollo local vía SAM local o LocalStack siempre tiene alguna pequeña diferencia que te aparece en producción. No es decisivo, pero el ciclo de iteración con Workers se siente más ágil.
Cold Starts en Producción: Los Números con Contexto Real
Para tener números honestos, instrumenté ambos sistemas con OpenTelemetry desde el primer mes de la prueba, enviando trazas a Axiom. Quería medir cold starts específicamente — que en OTel se manifiestan como un span de inicialización más largo en la primera invocación tras inactividad — y separar eso de la latencia de negocio real.
Las condiciones: endpoints de API REST con tráfico variable, períodos de baja actividad de madrugada, Node.js 20 en Lambda con 256MB de memoria asignada.
Los cold starts de Lambda en P50 andaban en 350-400ms. El P95 llegaba a 720ms. Y cuando había un pico de tráfico después de un período inactivo — el lunes a las 9am cuando varios clientes entraban casi simultáneamente — el P99 superaba 1.2 segundos. Para endpoints de una UI que el usuario está esperando, eso se nota.
Workers: P50 de 2ms para cold starts. P95 de 7ms. El valor más alto que registré en todo el período de prueba fue 21ms, y fue un outlier claro en el gráfico.
Para ser honesto con Lambda: hay herramientas para mitigar todo esto. Provisioned Concurrency mantiene instancias calientes y prácticamente elimina los cold starts, pero tiene costo fijo por hora independiente del tráfico. Para nuestra carga de trabajo — irregular, con picos diurnos y casi nada de madrugada — ese costo no se justificaba. SnapStart es la otra opción buena, pero es Java solamente.
Lambda mejoró los cold starts entre Node.js 18 y 20, y hay técnicas con pre-loading de módulos usando --import que reducen el tiempo de inicialización si te tomas el tiempo de configurarlo. Pero la diferencia con Workers sigue siendo de un orden de magnitud para cualquier función sin provisioned concurrency.
El Límite de CPU Que Nadie Menciona Hasta que te Explota en Producción
Acá viene la parte que quisiera haber leído antes de migrar, y el motivo por el que escribí este post.
Workers tiene un límite de CPU time: 10ms en el plan free, 50ms en el plan paid. No es wall-clock time. Si tu función hace await fetch(algunaApi) y espera 300ms a una respuesta externa, eso no cuenta. Es tiempo de CPU puro: el tiempo que el hilo de JavaScript está activamente ejecutando código.
La mayoría de tutoriales mencionan este límite en un párrafo pequeño con la nota de que “es suficiente para la mayoría de casos de uso”. Técnicamente cierto. En la práctica, más tramposo de lo que suena.
Migré nuestro endpoint de procesamiento de documentos a Workers. El endpoint recibe un JSON con hasta 200 registros, normaliza campos de texto con regex, calcula checksums y devuelve el resultado. En staging, con mis 10-20 registros de prueba, todo perfecto. Push a producción un viernes por la tarde — sé, sé — y a las dos horas los logs empezaban a mostrar errores 1101.
// workers/process-documents.js — la versión que explotó en prod
export default {
async fetch(request, env, ctx) {
const { records } = await request.json();
// Con 200 registros reales, este .map() consume ~70ms de CPU puro.
// El límite es 50ms. El isolate muere a la mitad sin un error claro.
const results = records.map(record => ({
id: record.id,
normalized: normalizeFields(record), // regex + string ops en varios campos
checksum: computeChecksum(record.data) // SubtleCrypto, operación costosa
}));
return Response.json({ results });
}
};
El error 1101 de Workers es un “Worker threw an exception” y los logs por defecto no te dicen mucho más. Tardé un par de horas en conectar los puntos. Lo que me confundió al principio es que la función fallaba en producción con payloads grandes pero pasaba todos mis tests — porque mis fixtures de test eran pequeños.
La solución inmediata fue dividir el procesamiento en chunks más pequeños y ceder el event loop entre ellos:
// workers/process-documents.js — versión que sí funciona
export default {
async fetch(request, env, ctx) {
const { records } = await request.json();
const CHUNK_SIZE = 35; // ~8ms de CPU por chunk, bien dentro del límite
const results = [];
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE);
for (const record of chunk) {
results.push({
id: record.id,
normalized: normalizeFields(record),
checksum: await computeChecksum(record.data) // async ahora
});
}
// scheduler.wait(0) cede el event loop y resetea el contador de CPU time.
// Requiere compat_date: "2023-03-01" en wrangler.toml
if (i + CHUNK_SIZE < records.length) {
await scheduler.wait(0);
}
}
return Response.json({ results });
}
};
Funciona. Pero ese endpoint no debería estar en Workers — y esa es la lección real. Es CPU-intensivo por naturaleza, y Workers está diseñado para workloads donde el cuello de botella es la red, no el CPU. Lo moví de vuelta a Lambda y no volví a tener el problema. A veces la solución correcta no es ajustar el código sino cuestionar si estás usando la herramienta correcta.
Dónde Lambda Sigue Ganando Sin Discusión
Hay casos donde Lambda gana por razones estructurales que Workers no puede resolver, y después de cinco meses tengo claro cuáles son.
El ecosistema de Node.js completo es el más obvio. Workers tiene un modo de compatibilidad (nodejs_compat en wrangler.toml) pero no es paridad total. Tuve que reescribir fragmentos que usaban Buffer de formas específicas, y hay paquetes npm que simplemente no funcionan porque dependen de APIs de Node.js fuera del subset de Workers. Si tu codebase tiene dependencias profundas del ecosistema Node.js, Lambda es la única opción que no implica reescribir dependencias de terceros.
Los jobs de larga duración son otro caso claro. Workers tiene un límite de 30 segundos de wall-clock time en el plan estándar. Lambda llega a 15 minutos. Para exports de datos, procesamiento de archivos pesados, o cualquier tarea que tome tiempo real, Workers simplemente no alcanza — aunque los Durable Objects pueden ser una solución para ciertos patrones, no es lo mismo.
Y si tu stack ya vive en AWS — DynamoDB, SQS, EventBridge, Step Functions — la integración es nativa y co-locada. Invocar DynamoDB desde un Worker en un datacenter de Cloudflare implica atravesar internet hasta la región de AWS correspondiente. Medí ~18ms adicionales en promedio para calls a DynamoDB desde Workers vs desde Lambda co-locada en us-east-1. No es el fin del mundo, pero se suma cuando haces múltiples calls por request.
La diversidad de runtimes también importa si tienes un equipo mixto. Lambda soporta Python, Java con SnapStart, Go, Rust vía custom runtime. Workers es JavaScript, TypeScript y WebAssembly. Si parte del equipo trabaja en Python para data processing, no hay discusión.
Los Números Reales y Mi Veredicto
Después de cinco meses con ~25M requests/mes en los endpoints migrados, los costos quedaron así:
Lambda (Node 20, 256MB, ~45ms de duración promedio): unos $11-13/mes dependiendo del tráfico del mes. Requests y duration costs combinados, después del free tier.
Workers (plan Paid): $5 base con 10M requests incluidas, más $4.50 por los 15M adicionales. Total ~$9.50/mes fijo y predecible.
La diferencia existe pero no cambia vidas a esta escala. A volúmenes mucho más altos — cientos de millones de requests — Workers escala más predeciblemente porque el costo de duración de Lambda crece con el tiempo de ejecución, mientras que Workers cobra por CPU time consumido. Si tus funciones pasan mucho tiempo esperando I/O, Lambda cobra ese tiempo también.
Honestamente, después de todo esto la decisión no es tan complicada. Workers gana cuando tus handlers son ligeros en CPU — autenticación, routing, rate limiting, proxying de requests, respuestas desde caché — y cuando sirves usuarios distribuidos globalmente donde los cold starts te están afectando. Si ya apuestas por el ecosistema Cloudflare, R2, KV y Durable Objects se integran bien entre sí y tiene sentido centralizar ahí. Lambda es la respuesta cuando tienes procesamiento CPU-intensivo, dependencias profundas en npm, necesitas múltiples runtimes o tu stack ya está integrado con servicios AWS. En esos casos no hay mucho que debatir.
Nosotros terminamos con arquitectura mixta: Workers maneja el routing global, autenticación JWT y respuestas desde Cloudflare KV; Lambda maneja el procesamiento de documentos, los jobs en background y todo lo que toca DynamoDB directamente. Operacionalmente es un poco más complejo de mantener — dos sistemas de deploy, dos sets de observabilidad, dos modelos de debugging — pero es honesto con lo que cada runtime hace bien.
No soy 100% seguro de que esta separación siga siendo la correcta conforme escalemos. Probablemente a mayor tamaño de equipo, el costo cognitivo de mantener dos runtimes distintos empiece a pesar más que los beneficios técnicos. Pero para donde estamos ahora, funciona mejor que tener todo en un solo lugar.