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.

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.
// ❌ 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.
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.
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.
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.
// ❌ 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:
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.
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".
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
- Pasa
AbortSignala 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 - Comprueba
signal.throwIfAborted()antes y entre pasos costosos, no solo a la entrada de la función - Compón las fuentes de cancelación con
AbortSignal.any()en lugar de improvisar cadenas de event listeners a mano - Limpia siempre los recursos en
finally, independientemente de si la función se completó, falló o fue abortada - Prueba tanto el camino "ya abortada" como el de "abortada en pleno vuelo" — ejercitan ramas de código distintas
- 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.


