Async generator en JavaScript: datos que llegan poco a poco
Qué es un async generator en JavaScript, cómo funcionan async function* y for await…of, y cuándo usarlos para paginar APIs o leer streams sin agotar memoria.

Una API te devuelve clientes de 100 en 100. Tienes que procesar 200.000.
El primer código que sale es casi siempre el mismo: un bucle que pide todas las páginas, mete los resultados en un array y, cuando termina, empieza a trabajar. Funciona en local con 300 registros. En producción el proceso tarda minutos en hacer lo primero útil, la memoria sube sin parar y, si la página 1.800 falla, pierdes las 1.799 anteriores.
El problema no es la API. Para esto existe el async generator en JavaScript (ES2018), una herramienta que mucha gente ha visto escrita pero pocos usan a propósito: tu código modela como "un valor que llega al final" algo que en realidad es "muchos valores que van llegando".
En corto: un async generator en JavaScript (async function*) produce valores asíncronos de uno en uno y el consumidor los recorre con for await...of. El productor solo avanza cuando el consumidor pide el siguiente valor, así que procesas datos paginados o en streaming sin cargarlos en memoria. Si el consumidor sale con break, el generador ejecuta su finally y libera recursos.
¿Qué es un async generator?
Un async generator es una función declarada con async function* que puede usar await y yield, y que al llamarla devuelve un objeto AsyncGenerator cuyo método next() devuelve una Promise<{ value, done }>.
Esa última parte es el protocolo entero. Un async iterator es cualquier objeto con un next() que devuelve una promesa de { value, done }. Un async iterable es cualquier objeto con un método [Symbol.asyncIterator]() que devuelve un async iterator. for await...of solo sabe hablar con esa interfaz.
async function* countdown(from: number): AsyncGenerator<number, string, undefined> {
for (let i = from; i > 0; i--) {
await new Promise((r) => setTimeout(r, 500)); // await: espera algo
yield i; // yield: entrega un valor y se pausa
}
return "despegue";
}
const it = countdown(2);
console.log(await it.next()); // { value: 2, done: false }
console.log(await it.next()); // { value: 1, done: false }
console.log(await it.next()); // { value: "despegue", done: true }
await pausa el generador para esperar algo de fuera. yield lo pausa para entregar algo, y no sigue hasta el siguiente next(). Según MDN, si haces yield de una promesa, el value que recibe el consumidor ya viene resuelto, nunca es otra promesa.
En TypeScript tienes dos tipos útiles. AsyncGenerator<T, TReturn, TNext> describe el objeto concreto: lo que entrega cada yield, lo que devuelve el return y lo que acepta next(valor). AsyncIterable<T> es el contrato mínimo, y es el que deberías pedir en los parámetros de tus funciones.
Paginar una API con un async generator en JavaScript
Un async generator pagina una API pidiendo la página siguiente solo cuando el consumidor ha terminado con la anterior. El caso de la apertura queda así:
type Page<T> = { items: T[]; nextCursor: string | null };
async function* paginate<T>(url: string, signal?: AbortSignal): AsyncGenerator<T, void, undefined> {
let cursor: string | null = null;
do {
const u = new URL(url);
if (cursor) u.searchParams.set("cursor", cursor);
const res = await fetch(u, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status} en ${u}`);
const page = (await res.json()) as Page<T>;
yield* page.items; // delega: entrega los items uno a uno
cursor = page.nextCursor;
} while (cursor);
}
for await (const customer of paginate<{ id: string }>("https://api.example.com/customers")) {
await processCustomer(customer);
}
El consumidor recibe el primer cliente en cuanto llega la primera página. yield* delega en otro iterable (aquí un array) y entrega sus elementos uno a uno. Y mientras processCustomer trabaja, el generador está parado en el yield: no pide la página siguiente hasta que hace falta.
Eso es backpressure natural: el productor nunca corre más que el consumidor. No hay buffer que crezca ni cola que vigilar. En memoria tienes, como mucho, una página.
Ese as Page<T> es una mentira piadosa. Si la API cambia un campo, te enteras en la página 400. Validar cada página que entra con un esquema es lo que enseño en el curso de Zod para TypeScript, y en un generador encaja en una línea: const page = PageSchema.parse(await res.json()).
for await…of y streams: leer chunk a chunk
El otro caso donde los generadores asíncronos brillan es un stream: el body de un fetch, SSE o los tokens de un LLM.
async function* lines(res: Response): AsyncGenerator<string, void, undefined> {
if (!res.body) return;
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const parts = buffer.split("\n");
buffer = parts.pop() ?? "";
yield* parts;
}
if (buffer) yield buffer;
} finally {
await reader.cancel(); // se ejecuta al terminar, al fallar o si el consumidor hace break
}
}
for await (const line of lines(await fetch("/logs"))) {
if (line.includes("FATAL")) break; // dispara return() -> finally -> cancel()
console.log(line);
}
El finally es la pieza que la gente olvida. Cuando el consumidor hace break, return o lanza una excepción dentro del for await, el bucle llama a return() sobre el generador, y el generador ejecuta su finally antes de cerrarse. Sin ese bloque, el reader queda bloqueado y la conexión abierta.
Este mismo patrón, aplicado a eventos SSE, es el que aparece en la API con Hono y Bun que hace streaming SSE. Si te interesa cómo se montan esos agentes por dentro, el ebook gratuito El Developer Agéntico lo cuenta de principio a fin.
Por cierto: hoy ReadableStream ya es async iterable y puedes hacer for await (const chunk of res.body) directamente. La decisión de que salir del bucle cancele el stream se discutió en el issue #778 de whatwg/streams, abierto en 2017 y cerrado en 2019. Domenic Denicola lo resumió así: "break invokes return(), which per the above design will cancel the stream". Para no cancelar está la opción preventCancel.
Ojo con Safari: según caniuse, la iteración con for await solo funciona desde Safari 27. Si tienes usuarios en versiones anteriores, getReader() sigue siendo la opción segura, y por eso el código de arriba lo usa.
Async generator vs Promise.all, RxJS y ReadableStream
Frente a Promise.all, RxJS y ReadableStream, el async generator es la opción más simple cuando la secuencia se consume una sola vez, en orden y por un único consumidor.
| Enfoque | Modelo | Memoria | Backpressure | Cancelación | Limitación / riesgo |
|---|---|---|---|---|---|
Promise.all + array |
Todo de golpe | Todos los resultados a la vez | No | Manual con AbortController |
No procesas nada hasta el final; un rechazo tumba el lote entero |
Generador síncrono (function*) |
Pull síncrono | Un valor a la vez | Sí | return() |
No puede esperar I/O: como mucho entrega promesas que tienes que esperar tú |
Async generator (async function*) |
Pull asíncrono | Un valor a la vez | Sí, natural | break / return() ejecuta finally |
Secuencial, una sola pasada, un solo consumidor |
| Observable (RxJS) | Push | Depende de los operadores | No nativa: hay que añadir operadores | unsubscribe() |
Dependencia externa; el productor puede inundar al consumidor |
ReadableStream |
Pull con cola interna | Chunks con highWaterMark |
Sí, configurable | cancel(); break cancela |
API verbosa; la iteración con for await llegó tarde a Safari |
Mi criterio: si la fuente es una secuencia que se consume una vez y en orden (páginas, líneas, tokens), el async generator es la opción más simple. Si hay varios suscriptores o eventos de UI, RxJS. Si estás conectando con APIs de la plataforma que ya hablan streams, ReadableStream.
Componer y recolectar: yield*, Array.fromAsync y helpers
Como un async generator acepta cualquier AsyncIterable, componerlos es trivial:
async function* take<T>(source: AsyncIterable<T>, n: number): AsyncGenerator<T, void, undefined> {
if (n <= 0) return;
let i = 0;
for await (const item of source) {
yield item;
if (++i >= n) return; // cierra también la fuente: su finally se ejecuta
}
}
const first50 = await Array.fromAsync(take(paginate<{ id: string }>("https://api.example.com/customers"), 50));
Array.fromAsync recoge un async iterable en un array. MDN lo marca como Baseline desde enero de 2024 y el repo de propuestas finalizadas de TC39 lo asigna a ES2026, no a ES2024. Espera cada valor de forma secuencial, igual que for await.
¿Y map, filter o take nativos? Los iterator helpers para iteradores síncronos son estándar desde ES2025. Los de iteradores asíncronos no: la propuesta async iterator helpers sigue en stage 2. Hasta entonces, el take de arriba lo escribes tú. Hablo de lo que sí entró al lenguaje este año en las novedades de ECMAScript 2026.
Errores típicos con for await y generadores asíncronos
Creer que for await paraleliza. No lo hace. Cada vuelta espera a la anterior. Si tienes 20 URLs independientes y cada petición se lanza cuando el bucle pide el siguiente valor (dentro del cuerpo o desde un generador), un for await tarda la suma de todas; Promise.all las lanza a la vez y tarda lo que la más lenta. El ejemplo de MDN lo muestra con un generador que crea cada promesa al pedirla: cinco promesas de 100 ms tardan unos 500 ms con Array.fromAsync y unos 100 ms con Promise.all. Si no tienes claro por qué, repasa cómo funcionan el event loop y las microtasks.
Recorrer un array de promesas con for await. Si la tercera se rechaza mientras esperas a la primera, nadie la escucha todavía y el runtime puede reportar un unhandled rejection. Para promesas que ya existen, Promise.all o Promise.allSettled.
No poner finally. Cualquier recurso abierto dentro del generador (reader, conexión, fichero) se queda colgado si el consumidor sale antes de tiempo.
No entender cómo viajan los errores. Un throw dentro del generador rechaza la promesa de next(), el for await relanza esa excepción en el consumidor y el generador queda terminado. Ponle el try/catch al bucle consumidor, no solo al generador. Si te pierdes siguiendo el rastro, aquí explico cómo depurar código asíncrono en JavaScript.
Intentar recorrerlo dos veces. Un generador es de una sola pasada. La segunda iteración no produce nada. Si necesitas reutilizarlo, expón un iterable que cree uno nuevo cada vez:
const customers: AsyncIterable<{ id: string }> = {
[Symbol.asyncIterator]: () => paginate<{ id: string }>("https://api.example.com/customers"),
};
Cuándo NO usar un async generator
Cuando los datos caben en un Promise.all. Si son diez peticiones independientes y el resultado cabe en memoria, un generador solo añade latencia por ser secuencial. La paginación por cursor es secuencial por naturaleza; la paginación por número de página no, y ahí un Promise.all por lotes suele ser más rápido.
Cuando hay varios consumidores. Un async generator entrega cada valor a un solo next(). Si dos partes de la app necesitan los mismos eventos (por ejemplo, un WebSocket que alimenta tres componentes), estás ante un problema de push y multicast. Ahí un Observable o un EventTarget encajan mejor.
Cuando el cuello de botella es la concurrencia. Hacer prefetch de la página siguiente o procesar N elementos en paralelo exige código extra. Si es tu caso principal, una cola o un ReadableStream con highWaterMark te ahorran reinventarlo.
Cuando necesitas cancelar una petición en vuelo. break solo actúa cuando el generador está pausado en un yield. Para cortar un fetch lento por timeout necesitas un AbortSignal, como el parámetro signal de paginate:
// Corta el recorrido entero, incluida la petición en vuelo, si pasa de un minuto
const customers = paginate<{ id: string }>("https://api.example.com/customers", AbortSignal.timeout(60_000));
Qué hacer con esto hoy
Busca en tu código un bucle de fetch que acumula resultados en un array solo para recorrerlo después. Conviértelo en un async function* con yield y finally, y consume con for await...of. El consumidor apenas cambia; la memoria y el tiempo hasta el primer resultado, sí.
Si estás construyendo productos que consumen modelos en streaming, esto es la base de todo lo demás. En Construye con IA lo aplicamos de la idea al producto con Claude Code.
Preguntas frecuentes
¿Cuál es la diferencia entre un generador y un async generator?
En un generador (function*), next() devuelve { value, done } al instante y se recorre con for...of. En un async generator (async function*), next() devuelve una promesa de { value, done }, dentro puedes usar await y se recorre con for await...of.
¿for await…of ejecuta las promesas en paralelo?
No. for await...of espera a que se resuelva cada valor antes de pedir el siguiente. Si necesitas paralelismo sobre promesas independientes, usa Promise.all o Promise.allSettled.
¿Qué pasa si hago break dentro de un for await…of?
El bucle llama a return() sobre el iterador. En un async generator eso ejecuta el bloque finally pendiente y marca el generador como terminado. En un ReadableStream, por defecto, cancela el stream salvo que uses values({ preventCancel: true }).
¿Cómo se tipa un async generator en TypeScript?
Con AsyncGenerator<T, TReturn, TNext>, donde T es el tipo de cada yield, TReturn el del return y TNext lo que acepta next(). Para los parámetros de tus funciones es mejor pedir AsyncIterable<T>, que acepta generadores, streams y cualquier objeto con [Symbol.asyncIterator].
¿Puedo usar map y filter sobre un async iterator?
No de forma nativa todavía. Los iterator helpers síncronos son estándar desde ES2025, pero los async iterator helpers siguen en stage 2 en TC39. Mientras tanto, escribe tus propios map o take como async generators, o usa una librería.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.