Saltar al contenido

Patrones de cancelación en TypeScript: domando AbortController

Por qué casi todo el código asíncrono en TypeScript ignora la cancelación, y cómo construir una cancelación componible y sin fugas con AbortController.

5 min de lectura
Código TypeScript que demuestra cadenas de cancelación con AbortController a través de operaciones asíncronas

La mayor parte del código asíncrono en una base de código típica de TypeScript no tiene ningún concepto de "detener". Un usuario navega a otra página, una petición expira, una operación padre falla — y el fetch en curso, la consulta a la base de datos o la tarea en segundo plano simplemente sigue ejecutándose, quemando CPU y manteniendo conexiones abiertas para un trabajo que ya nadie quiere. La cancelación no es un caso borde. Es una parte de primera clase del diseño asíncrono que se añade demasiado tarde, si es que se añade.

AbortController lleva años disponible en Node.js y en los navegadores, pero la mayoría de los equipos lo usan como un truco puntual para poner timeout a un fetch, en lugar de como una estrategia sistémica de cancelación. Eso es una oportunidad desaprovechada.

Por qué se ignora la cancelación

El modelo mental por defecto de async/await trata una promesa como un compromiso de fuego y olvido: una vez iniciada, se ejecuta hasta completarse. No hay una forma incorporada de decirle a una función async en ejecución "detente, ya no necesito esto". Sin una señal que comprobar, la función no tiene ni idea de que el llamador ya pasó página.

tstypescript
// ❌ No way to stop this once it starts
async function fetchUserOrders(userId: string) {
  const user = await db.users.findById(userId);
  const orders = await db.orders.findByUserId(userId);
  const enriched = await enrichWithShippingData(orders);
  return enriched;
}
 
// If the caller times out or the request is aborted,
// this keeps running — three sequential queries, wasted work.

La solución no es un try/catch más grande. Es pasar una señal a través de cada capa que pueda tomar un tiempo significativo.

El contrato de AbortSignal

AbortController te da un objeto signal que empieza como "no abortado" y transita una única vez, de forma permanente, a "abortado". Cualquier función que haga trabajo asíncrono debería aceptar una señal y comprobarla — tanto antes de empezar trabajo costoso como aprovechando que los drivers de fetch/BD lo soportan de forma nativa.

tstypescript
async function fetchUserOrders(
  userId: string,
  signal?: AbortSignal,
): Promise<EnrichedOrder[]> {
  signal?.throwIfAborted();
 
  const user = await db.users.findById(userId, { signal });
  signal?.throwIfAborted();
 
  const orders = await db.orders.findByUserId(userId, { signal });
  signal?.throwIfAborted();
 
  return enrichWithShippingData(orders, signal);
}

throwIfAborted() lanza un AbortError (bueno, un DOMException con nombre "AbortError") inmediatamente si la señal ya se disparó. Es un seguro barato contra continuar un trabajo que ya no tiene sentido.

i

La mayoría de las bibliotecas modernas — fetch, undici, pg (con un wrapper), fs.readFile de Node, child_process — aceptan una opción signal. Compruébalo antes de escribir tu propio bucle de polling.

Componiendo timeouts y cancelación manual

Los sistemas reales necesitan combinar múltiples fuentes de cancelación: un timeout a nivel de petición, un botón de cancelar iniciado por el usuario y la propia señal de aborto de una operación padre. AbortSignal.any() (Node 20+, todos los navegadores modernos) compone señales sin cableado manual de eventos.

tstypescript
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
  const timeoutController = new AbortController();
  const timer = setTimeout(() => timeoutController.abort(new Error("Timeout")), ms);
 
  const combined = signal
    ? AbortSignal.any([signal, timeoutController.signal])
    : timeoutController.signal;
 
  // Clean up the timer once resolved either way to avoid leaking it
  combined.addEventListener("abort", () => clearTimeout(timer), { once: true });
 
  return combined;
}
 
// Usage: request-level abort AND a hard 5s ceiling
async function handleRequest(req: Request, requestSignal: AbortSignal) {
  const signal = withTimeout(requestSignal, 5_000);
  return fetchUserOrders(req.userId, signal);
}

Antes de que existiera AbortSignal.any(), los equipos reimplementaban esto con cadenas manuales de addEventListener dispersas por toda la base de código — inconsistente, y fácil que se filtren listeners. La componibilidad es todo el punto.

Cancelación en bucles y lotes

Los bucles de larga duración son donde más importa la cancelación, y donde más a menudo se olvida. Comprobar la señal solo al principio de una función no ayuda si la función luego itera sobre 50.000 filas.

tstypescript
// ❌ Ignores the signal for the entire duration of the loop
async function processRecords(records: Record[], signal?: AbortSignal) {
  for (const record of records) {
    await processOne(record);
  }
}
 
// ✅ Checks between iterations — bails out promptly, not eventually
async function processRecords(records: Record[], signal?: AbortSignal) {
  for (const record of records) {
    signal?.throwIfAborted();
    await processOne(record);
  }
}

Para bucles síncronos limitados por CPU (sin await dentro), comprobar en cada iteración es un desperdicio. Comprueba cada N iteraciones en su lugar — el número exacto depende de lo costosa que sea cada iteración:

tstypescript
function computeHashes(items: string[], signal?: AbortSignal): string[] {
  const results: string[] = [];
  for (let i = 0; i < items.length; i++) {
    if (i % 1_000 === 0) signal?.throwIfAborted();
    results.push(hash(items[i]));
  }
  return results;
}

Limpiando recursos al abortar

Lanzar una excepción al abortar es solo la mitad del trabajo. Los recursos que la operación adquirió — handles de archivo, transacciones de BD, archivos temporales — necesitan limpieza independientemente de cómo salga la función. try/finally se encarga de esto, pero es fácil pasarlo por alto cuando una función tiene múltiples caminos de retorno anticipado.

tstypescript
async function exportReport(query: ReportQuery, signal?: AbortSignal) {
  const connection = await pool.acquire();
  const tempFile = await createTempFile();
 
  try {
    signal?.throwIfAborted();
    const rows = await connection.query(query.sql, { signal });
    await writeRowsToFile(tempFile, rows, signal);
    return tempFile;
  } finally {
    // Runs on success, on error, and on abort — no resource leak
    await connection.release();
    if (signal?.aborted) await deleteTempFile(tempFile);
  }
}

Es la misma disciplina que con el pooling de conexiones — el bloque finally es tu garantía de que la cancelación no se convierte en una fuga de recursos.

Probando los caminos de cancelación

La lógica de cancelación que nunca se prueba es lógica de cancelación que está rota. Simula el aborto con una señal ya disparada y otra con retardo para cubrir tanto el caso "ya cancelada" como el de "cancelada en pleno vuelo".

tstypescript
import { test, expect } from "vitest";
 
test("throws immediately if already aborted", async () => {
  const controller = new AbortController();
  controller.abort();
 
  await expect(
    fetchUserOrders("user-1", controller.signal),
  ).rejects.toMatchObject({ name: "AbortError" });
});
 
test("stops mid-flight when aborted during execution", async () => {
  const controller = new AbortController();
  const promise = processRecords(bigRecordSet, controller.signal);
 
  setTimeout(() => controller.abort(), 10);
 
  await expect(promise).rejects.toMatchObject({ name: "AbortError" });
});

Puntos clave

  1. Pasa AbortSignal a través de cada función asíncrona que haga trabajo significativo — trátalo como un parámetro obligatorio, no como una ocurrencia tardía
  2. Comprueba signal.throwIfAborted() antes y entre pasos costosos, no solo a la entrada de la función
  3. Compón las fuentes de cancelación con AbortSignal.any() en lugar de improvisar cadenas de event listeners a mano
  4. Limpia siempre los recursos en finally, independientemente de si la función se completó, falló o fue abortada
  5. Prueba tanto el camino "ya abortada" como el de "abortada en pleno vuelo" — ejercitan ramas de código distintas
  6. Usa comprobaciones por intervalos en bucles limitados por CPU para evitar la sobrecarga de comprobar en cada iteración

La cancelación no es un extra opcional que añades cuando alguien se queja de una petición desbocada. Es una señal que debería existir dondequiera que exista trabajo asíncrono — porque en el momento en que la omites, has escrito código que asume que cada operación que inicias es una que realmente necesitarás terminar.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX