DEV Community

Cover image for Kotlin Coroutines to TypeScript: Three Cancellation Patterns That Aren't 1:1
Gabriel Anhaia
Gabriel Anhaia

Posted on

Kotlin Coroutines to TypeScript: Three Cancellation Patterns That Aren't 1:1


A Kotlin engineer ports their first non-trivial service to Node. The handler launches three calls in parallel, the request times out, and the engineer expects the in-flight work to stop. It does not. Two of the three downstream calls finish ten seconds later, write to the database, and emit a metric for a request whose response was already a 504.

In Kotlin, that bug does not exist. Every coroutineScope and every launch belongs to a parent. The runtime cancels every child recursively and atomically when the parent is cancelled. It is not a library feature. It is the central design decision of kotlinx.coroutines. The Kotlin docs call it structured concurrency.

JavaScript has none of this. A Promise does not have cancel(). Once you call a function that returns one, the work inside is unaccountable to its caller. Nothing says "the parent died, you should stop." That hole has been filled three different ways in the TypeScript world, and they are not interchangeable.

What follows is the JVM engineer's map: three patterns, what they cost, and which one matches the Kotlin habit you actually need.

What you're losing when you leave Kotlin

A Kotlin function that does three calls and waits for all of them looks like this:

suspend fun loadProfile(id: String): Profile = coroutineScope {
    val user = async { userClient.fetch(id) }
    val perms = async { permsClient.fetch(id) }
    val flags = async { flagsClient.fetch(id) }
    Profile(user.await(), perms.await(), flags.await())
}
Enter fullscreen mode Exit fullscreen mode

Three things are free here:

  1. If the caller is cancelled, all three async blocks are cancelled. The HTTP clients receive the cancellation through coroutineContext and stop reading from their sockets.
  2. If userClient.fetch throws, the other two are cancelled before the exception propagates. No orphaned work.
  3. The function returns when all three children are done. It cannot return while a child is still running. That is the structured-concurrency contract.

The TypeScript equivalent that looks similar is not similar:

async function loadProfile(id: string): Promise<Profile> {
  const [user, perms, flags] = await Promise.all([
    userClient.fetch(id),
    permsClient.fetch(id),
    flagsClient.fetch(id),
  ]);
  return { user, perms, flags };
}
Enter fullscreen mode Exit fullscreen mode

If the caller goes away, the three fetch promises keep going. Promise.all rejecting on the first error does not cancel the other two; the other two finish on their own time and resolve into a request that no longer has a caller. The function does not know it has lost an audience.

That is the gap. Three patterns close it. They are not equivalent.

Kotlin's parent scope cancelling children versus a Promise chain that keeps running with no scissors: the cancellation gap as a single image

Pattern 1: AbortController, passed down by hand

AbortController is the platform answer. It ships in Node 16+, every browser, Bun, and Deno. The shape is one controller, many signals, all derived from the same source.

type Profile = { user: User; perms: Perms; flags: Flags };

async function loadProfile(
  id: string,
  signal: AbortSignal,
): Promise<Profile> {
  const [user, perms, flags] = await Promise.all([
    userClient.fetch(id, { signal }),
    permsClient.fetch(id, { signal }),
    flagsClient.fetch(id, { signal }),
  ]);
  return { user, perms, flags };
}

// caller
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 2_000);
try {
  const profile = await loadProfile("u-42", ac.signal);
  return profile;
} finally {
  clearTimeout(t);
}
Enter fullscreen mode Exit fullscreen mode

What this gives you, if every layer cooperates:

  • fetch is built to honor AbortSignal. The socket closes. The promise rejects with an AbortError.
  • The 2s deadline cascades. One controller, one abort, all three fetch calls die together.
  • Your own code can subscribe: signal.addEventListener('abort', () => ...) to clean up timers, file handles, listeners.

What it does not give you, and where the JVM habit hurts:

  • Every async function in the chain has to take a signal parameter and pass it on. Forget one and the cancellation stops there. There is no coroutineContext you can read implicitly.
  • Cancellation does not propagate to a setTimeout you forgot to wire up, a CPU-bound loop, or a third-party library that never accepted a signal.
  • It is a contract, honored only when every link cooperates.

For services where the work is mostly fetch calls and database queries to clients that already accept AbortSignal (at the time of writing: undici, node-fetch, pg, mongodb, the @aws-sdk/* v3 clients, OpenAI's SDK), this pattern is enough. Most typical Node handlers fall in that bucket.

The cost is the boilerplate. Every internal helper grows a parameter. Every test grows a controller. The codebase visibly carries the cost of cancellation everywhere it goes. That is honest, but verbose.

AbortSignal.timeout(ms) and AbortSignal.any([...]) (Node 20+, modern browsers) help. Combine a request signal with a 2s deadline and you avoid managing the timer by hand:

const reqSignal = req.signal; // from the framework
const signal = AbortSignal.any([
  reqSignal,
  AbortSignal.timeout(2_000),
]);
const profile = await loadProfile("u-42", signal);
Enter fullscreen mode Exit fullscreen mode

That is closer to the Kotlin feel. One signal, derived from two parents, cancels when either does.

Pattern 2: cancellation token with explicit checkpoints

AbortController is enough for I/O. It is not enough for CPU work, because a tight JS loop never yields to check the signal. You need the equivalent of Kotlin's ensureActive(): a checkpoint your code calls between chunks.

The pattern is a thin token wrapping the signal, with a method that throws if cancelled:

class CancellationToken {
  constructor(public readonly signal: AbortSignal) {}

  static fromSignal(signal: AbortSignal): CancellationToken {
    return new CancellationToken(signal);
  }

  throwIfCancelled(): void {
    if (this.signal.aborted) {
      throw this.signal.reason ?? new Error("cancelled");
    }
  }
}

// CPU-bound worker that respects the token
function processBatch(
  items: Item[],
  token: CancellationToken,
): Result[] {
  const out: Result[] = [];
  for (let i = 0; i < items.length; i++) {
    if ((i & 0xff) === 0) token.throwIfCancelled(); // every 256 items
    out.push(transform(items[i]));
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

The shape mirrors Kotlin's yield() and ensureActive(). You decide where the checkpoints land. You pay for them with one branch per chunk, not one per item.

The same token works across mixed I/O and CPU work, because it carries the same AbortSignal underneath:

async function pipeline(
  ids: string[],
  token: CancellationToken,
): Promise<Result[]> {
  const raw = await fetchMany(ids, { signal: token.signal });
  token.throwIfCancelled(); // cheap re-check between phases
  const processed = processBatch(raw, token);
  token.throwIfCancelled();
  return saveMany(processed, { signal: token.signal });
}
Enter fullscreen mode Exit fullscreen mode

Two things this pattern gets right that bare AbortSignal misses:

  • It puts a single, named verb on the cancellation contract. token.throwIfCancelled() reads as one idea; if (signal.aborted) throw signal.reason is plumbing.
  • It centralizes the throw shape. Every cancellation throws the same error type, which makes the catch sites uniform: if (isAbortError(e)) ... else ....

When does the token earn the wrapper class? When CPU-bound work or long-lived background jobs need many checkpoints, or when the codebase has more than one library that defines its own abort error. The wrapper hides the divergence.

When is it overkill? Pure fetch-and-await services. Stay with AbortSignal.

Three TypeScript cancellation strategies as labeled outlets: AbortController for I/O, cancellation token for CPU checkpoints, Effect Fiber for structured concurrency

Pattern 3: Effect.ts Fiber for structured concurrency

Effect is the TypeScript ecosystem's answer to the question "what would a kotlinx.coroutines-shaped runtime look like in JS?" The unit is a Fiber. Every Fiber has a parent. Cancellation propagates down the tree. A timeout on the parent cancels every child mid-flight.

The previous example in Effect:

import { Effect, Duration } from "effect";

const loadProfile = (id: string) =>
  Effect.all(
    {
      user: userClient.fetch(id),
      perms: permsClient.fetch(id),
      flags: flagsClient.fetch(id),
    },
    { concurrency: "unbounded" },
  );
// `Effect.all` interrupts siblings on the first failure by default.

const program = loadProfile("u-42").pipe(
  Effect.timeout(Duration.seconds(2)),
);

Effect.runPromise(program);
Enter fullscreen mode Exit fullscreen mode

Effect.timeout does what withTimeout does in Kotlin. The three children are real Fibers. When the timeout fires, the runtime walks the Fiber tree and interrupts every descendant. The HTTP clients you wired through Effect (or via Effect.tryPromise with a cleanup) see the interruption and stop. The function as a whole returns a typed TimeoutException in its error channel. You also get the Kotlin-style "fail fast on the first error" for free, because that is how Effect.all already behaves.

This is the closest TypeScript gets to writing Kotlin. The cost is the rest of Effect: the Effect<A, E, R> type, the runtime, the learning curve, and the fact that many libraries you use will need to be wrapped with Effect.tryPromise and an explicit cancel callback before they participate in interruption.

When does it earn the cost? When the Kotlin habit you actually want is the one you can't fake: cascading cancellation through code your team writes, with deadlines composed from multiple parents, retries-as-values, and a typed error channel that survives to the edge of the program. Long-running background pipelines, agent loops with budgets, event-driven systems with structured supervision. That's where Effect's Fiber model earns the ramp.

When is it overkill? Most CRUD services. The cancellation needs of a typical handler do not justify the language-inside-a-language. Use AbortController, accept the boilerplate, ship.

A decision rule that holds up

                       does the work include CPU loops
                       or library code that ignores AbortSignal?
                                |
                  no            |            yes
            +-------------------+-------------------+
            |                                       |
   AbortController only                    do you also need:
   (Pattern 1)                              - cascading cancel through
   - fetch + DB clients                       a deep call tree
   - Plain Promise.all                      - typed error channel
   - AbortSignal.any                        - retry/schedule as values
   - AbortSignal.timeout                       |
                                              no | yes
                                    +------------+-------------+
                                    |                          |
                            CancellationToken              Effect.ts Fiber
                            (Pattern 2)                    (Pattern 3)
                            - explicit checkpoints         - structured concurrency
                            - one named verb                 - language-grade
                            - mixed I/O + CPU                - high ramp
Enter fullscreen mode Exit fullscreen mode

Three lines summarise it:

  • I/O only, mainstream clients: AbortController. Done.
  • I/O plus CPU, or you want one named cancel verb: wrap a CancellationToken around the signal.
  • The Kotlin shape is the actual requirement (cascading cancel, deadlines, typed errors as part of the type): pay for Effect.

What does not translate, and you should stop trying

A few Kotlin habits do not survive the trip. Knowing them up front saves a sprint.

  • runBlocking has no honest equivalent. JS has one event loop. Blocking it stalls the process. If you find yourself wishing for it, the answer is to keep the function async all the way up.
  • Cancellation is not free safety in the way it is in Kotlin. A Kotlin coroutine that ignores cancellation is a bug; a JS function that ignores AbortSignal is the default. You are negotiating a contract, not standing on a runtime guarantee.
  • Job and SupervisorJob map roughly to "a parent that doesn't fail when a child fails." In plain Promise-land you simulate it with Promise.allSettled. In Effect, you get Effect.forkDaemon and supervision policies. There is no platform answer.

JS gives you platform-level cancellation only for I/O. Everything else is a contract you wrote yourself.

Wire your next handler with AbortSignal.any([req.signal, AbortSignal.timeout(2_000)]). That single change closes the gap that kicked this post off: the orphaned fetch that wrote a row ten seconds after the request was already a 504. Most of the cancellation discipline you miss from Kotlin is one signal away.


If this was useful

The Kotlin and Java to TypeScript move is full of patterns where the names match and the semantics drift. Cancellation is the loudest example; null safety, sealed types to discriminated unions, variance, and suspend to async/await are the others. Kotlin and Java to TypeScript — A Bridge for JVM Developers walks through each one, with the same comparison-and-decision structure as this post.

If you want the broader set, the five-book TypeScript Library covers the core type system, foundations, the JVM and PHP bridges, and production tooling. Pick the bridge if you're coming from the JVM; add the type-system book once you're past the syntax; the production volume is for anyone shipping TS at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)