Zum Inhalt springen

Cancellation-Patterns in TypeScript: AbortController gezähmt

Warum der meiste asynchrone TypeScript-Code Cancellation ignoriert — und wie man mit AbortController komponierbares, leckfreies Abbrechen aufbaut.

4 Min. Lesezeit
TypeScript-Code, der AbortController-Cancellation-Ketten über asynchrone Operationen hinweg demonstriert

Der Großteil des asynchronen Codes in einer typischen TypeScript-Codebase kennt kein „Stopp". Ein Nutzer navigiert weiter, ein Request läuft in ein Timeout, eine übergeordnete Operation schlägt fehl — und der laufende fetch, die Datenbankabfrage oder der Hintergrundtask läuft einfach weiter, verbrät CPU und hält Verbindungen für Arbeit offen, die niemand mehr will. Cancellation ist kein Randfall. Sie ist ein First-Class-Bestandteil von Async-Design, der zu spät drangeflanscht wird — wenn überhaupt.

AbortController ist seit Jahren in Node.js und Browsern verfügbar, aber die meisten Teams benutzen ihn als einmaligen fetch-Timeout-Trick statt als systemische Cancellation-Strategie. Das ist vertanes Potenzial.

Warum Cancellation ignoriert wird

Das Standard-Denkmodell für async/await behandelt ein Promise als Fire-and-Forget-Zusage: Einmal gestartet, läuft es bis zum Abschluss. Es gibt keinen eingebauten Weg, einer laufenden async-Funktion zu sagen: „Stopp, ich brauche das nicht mehr." Ohne Signal zum Prüfen hat die Function keine Ahnung, dass der Aufrufer längst weitergezogen ist.

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.

Die Lösung ist kein größeres try/catch. Sie besteht darin, ein Signal durch jede Schicht zu fädeln, die nennenswert Zeit brauchen kann.

Der AbortSignal-Vertrag

AbortController gibt dir ein signal-Objekt, das als „nicht abgebrochen" startet und einmalig, unwiderruflich in „abgebrochen" übergeht. Jede Function, die asynchrone Arbeit verrichtet, sollte ein Signal akzeptieren und prüfen — sowohl vor dem Start teurer Arbeit als auch dort, wo fetch/DB-Treiber es nativ unterstützen.

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() wirft sofort einen AbortError (genauer: eine DOMException mit dem Namen "AbortError"), wenn das Signal bereits ausgelöst wurde. Eine billige Versicherung dagegen, Arbeit fortzusetzen, die längst sinnlos ist.

i

Die meisten modernen Libraries — fetch, undici, pg (mit einem Wrapper), Nodes fs.readFile, child_process — akzeptieren eine signal-Option. Prüfe das, bevor du deine eigene Polling-Schleife schreibst.

Timeouts und manuelle Cancellation komponieren

Echte Systeme müssen mehrere Cancellation-Quellen kombinieren: ein Timeout auf Request-Ebene, einen vom Nutzer betätigten Abbrechen-Button und das eigene Abort-Signal einer übergeordneten Operation. AbortSignal.any() (Node 20+, alle modernen Browser) komponiert Signale ohne manuelle Event-Verdrahtung.

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);
}

Bevor es AbortSignal.any() gab, haben Teams das mit manuellen addEventListener-Ketten nachgebaut, verstreut über die ganze Codebase — inkonsistent, und Listener zu leaken ist leicht. Komponierbarkeit ist genau der Punkt.

Cancellation in Schleifen und Batches

Lang laufende Schleifen sind der Ort, an dem Cancellation am wichtigsten ist — und am häufigsten vergessen wird. Das Signal nur am Anfang einer Function zu prüfen hilft nichts, wenn die Function danach über 50.000 Zeilen iteriert.

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);
  }
}

Bei CPU-gebundenen synchronen Schleifen (ohne await im Inneren) ist es Verschwendung, bei jeder Iteration zu prüfen. Prüfe stattdessen jede N-te Iteration — die genaue Zahl hängt davon ab, wie teuer jede Iteration ist:

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;
}

Ressourcen beim Abbrechen aufräumen

Eine Exception beim Abort zu werfen ist nur die halbe Arbeit. Die Ressourcen, die die Operation angefordert hat — File-Handles, DB-Transaktionen, Temp-Dateien — müssen aufgeräumt werden, egal wie die Function endet. try/finally erledigt das, aber es ist leicht zu übersehen, wenn eine Function mehrere Early-Return-Pfade hat.

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);
  }
}

Das ist dieselbe Disziplin wie beim Connection-Pooling — der finally-Block ist deine Garantie, dass aus Cancellation kein Ressourcenleck wird.

Cancellation-Pfade testen

Cancellation-Logik, die nie getestet wird, ist kaputte Cancellation-Logik. Simuliere den Abort mit einem bereits ausgelösten Signal und einem verzögerten, um sowohl den Fall „bereits abgebrochen" als auch „mitten im Flug abgebrochen" abzudecken.

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" });
});

Die wichtigsten Erkenntnisse

  1. Fädle AbortSignal durch jede async Function, die nennenswerte Arbeit leistet — behandle es wie einen Pflichtparameter, nicht wie einen nachträglichen Einfall
  2. Prüfe signal.throwIfAborted() vor und zwischen teuren Schritten, nicht nur beim Function-Einstieg
  3. Komponiere Cancellation-Quellen mit AbortSignal.any(), statt Event-Listener-Ketten von Hand zu bauen
  4. Räume Ressourcen immer in finally auf, unabhängig davon, ob die Function erfolgreich war, fehlschlug oder abgebrochen wurde
  5. Teste sowohl den Pfad „bereits abgebrochen" als auch „mitten im Flug abgebrochen" — sie durchlaufen unterschiedliche Code-Branches
  6. Nutze intervallbasierte Prüfungen in CPU-gebundenen Schleifen, um den Overhead einer Prüfung bei jeder Iteration zu vermeiden

Cancellation ist kein Nice-to-have, das du hinzufügst, sobald sich jemand über einen außer Kontrolle geratenen Request beschwert. Es ist ein Signal, das überall dort existieren sollte, wo asynchrone Arbeit existiert — denn in dem Moment, in dem du es weglässt, hast du Code geschrieben, der annimmt, dass jede Operation, die du startest, auch eine ist, die du tatsächlich zu Ende bringen musst.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX