DEV Community

Cover image for `suspend` Is `await`, Not `async`: A Kotlin-to-TypeScript Bridge
Gabriel Anhaia
Gabriel Anhaia

Posted on

`suspend` Is `await`, Not `async`: A Kotlin-to-TypeScript Bridge


A Kotlin developer joins a TypeScript codebase. They write a fetch helper. In Kotlin it looked like this:

suspend fun fetchUser(id: String): User {
    val resp = httpClient.get("/users/$id")
    return resp.body()
}
Enter fullscreen mode Exit fullscreen mode

Clean. Looks synchronous. They translate it to TypeScript, mark it async, and ship. A week later their PR has a dozen calls that look like this:

async function fetchUser(id: string): Promise<User> {
  const resp = await httpClient.get(`/users/${id}`);
  return resp.body();
}

// somewhere in tests
const user = fetchUser("u_42");
expect(user.name).toBe("Ada"); // why is user a Promise
Enter fullscreen mode Exit fullscreen mode

Race conditions in tests. A Map<string, User> whose values are actually all pending promises. Most Kotlin developers who move to JavaScript write some version of this bug, because the keywords look like the same idea and they aren't.

The fix is not new syntax. The syntax is small. The fix is realising suspend and async are not on the same axis. Once you internalise that, the rest of the move falls out of three or four sentences each: Flow, cancellation, dispatchers, structured concurrency.

The mental model: suspend is a call-site marker, async is a producer marker

Roman Elizarov makes this point cleanly in his KotlinConf "Deep Dive into Coroutines" talk: a suspend modifier in Kotlin is a contract that the function can suspend the calling coroutine. It's a call-site marker. You can only call a suspend function from another suspend function or from inside a coroutine builder.

JavaScript's await is the call-site marker. It says "this expression is a Promise, pause this function until it resolves". You can only await inside an async function or at the top level of an ES module.

The call-site axis maps cleanly:

Kotlin TypeScript
call a suspend fun await a Promise
inside a suspend fun inside an async function
inside runBlocking { } top-level await in an ESM
inside launch { } fire-and-forget IIFE

The producer axis is where the names mislead. In Kotlin, a function that returns a value asynchronously is just suspend fun foo(): T. There is no separate "this returns a future" annotation, because the coroutine machine erases the distinction at the call site.

In TypeScript, a function that returns a Promise<T> is async function foo(): Promise<T>. The async keyword is the producer marker. It tells the runtime "this body returns a Promise". It does not mean "this function suspends". A non-async function that returns a Promise directly is identical from the caller's point of view:

function foo(): Promise<User> {
  return httpClient.get("/users/42").then(r => r.body());
}

async function bar(): Promise<User> {
  const r = await httpClient.get("/users/42");
  return r.body();
}
Enter fullscreen mode Exit fullscreen mode

Both are Promise<User>-producing. The caller writes await foo() and await bar() identically. The async keyword changes the body's syntax (you can use await inside) and wraps the return in a Promise. That is its whole job.

So the mapping is:

  • Kotlin suspend (call-site contract) ≈ JS await (call-site marker).
  • Kotlin "this fn returns a value asynchronously" (no keyword) ≈ JS async (producer marker that returns a Promise).

Different axes. Treat them as if they're the same axis and you write the bug at the top of this post. Joffrey Bion's Medium piece "suspend is JS's await, not async" lands on the same observation from a Kotlin-first angle. I recommend shipping it to a JVM team on day one.

What "implicit await" means in practice

Every suspend function call in Kotlin behaves as if there's an implicit await in front of it. The compiler inserts the continuation machinery for you.

suspend fun loadProfile(id: String): Profile {
    val user    = fetchUser(id)        // implicit await
    val orders  = fetchOrders(id)      // implicit await
    return Profile(user, orders)
}
Enter fullscreen mode Exit fullscreen mode

The TypeScript translation needs explicit await on every line:

async function loadProfile(id: string): Promise<Profile> {
  const user   = await fetchUser(id);
  const orders = await fetchOrders(id);
  return { user, orders };
}
Enter fullscreen mode Exit fullscreen mode

Forget one await, and you assign a Promise<User> into a variable typed User. TypeScript helps a little — the compiler will yell when types line up cleanly. It will not yell when results are Promise<unknown>, when types come from JSON.parse, or when the destination is any. That is the production bug.

The Kotlin habit is "this looks synchronous, I trust it". The TypeScript daily driver is "every Promise-returning call gets a visible await or it's a bug".

A second trap: passing an async function reference into .map. The result is an array of Promises, not values. You need Promise.all for parallel, or a for...of loop with await for serial. In Kotlin this is the difference between map { fetchUser(it) } inside coroutineScope and awaitAll(...). The shape is the same; the names differ.

Single-thread reality: there is no other thread to suspend on

This is the bigger shift. Kotlin coroutines on Dispatchers.Default (or IO) get scheduled across a thread pool. Two coroutines can genuinely run in parallel on different cores. Shared mutable state needs a Mutex or AtomicInteger or Channel to stay safe.

JavaScript has one execution thread. The event loop runs your code, services microtasks, and runs more code. await yields control back to the loop. While your function is paused, other callbacks and other promise continuations run on the same thread. That's the whole concurrency model in user-land Node, browser tabs, Bun's main isolate, Deno's main isolate.

What that means for code you bring over from Kotlin:

// Kotlin — this needs a Mutex, two threads can race on it
class Counter {
    private var n = 0
    suspend fun inc(): Int {
        n += 1
        return n
    }
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript — no Mutex needed for this exact shape
class Counter {
  private n = 0;
  async inc(): Promise<number> {
    this.n += 1;
    return this.n;
  }
}
Enter fullscreen mode Exit fullscreen mode

Why is the TS version safe? Because this.n += 1 is one synchronous expression. There is no await between the read and the write. No other coroutine can interleave. The single-threaded event loop guarantees it.

Add an await in the middle and the guarantee evaporates:

class Counter {
  private n = 0;
  async incAfterFetch(): Promise<number> {
    const current = this.n;
    await someNetworkCall(); // yields to the event loop
    this.n = current + 1;     // racy
    return this.n;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two concurrent calls to incAfterFetch will both read n = 0, both wait, both write 1. Lost update. The bug is interleaved continuations on the same thread, even though there's only one of them. Kotlin coroutines have the same interleaving hazard on a single dispatcher; developers familiar with coroutines tend to frame it as "the suspension yielded, recheck invariants".

Practical rule: in TypeScript, every await is a yield point. Treat everything between two awaits as one atomic block. Anything that crosses an await needs to be re-checked or guarded.

For genuine CPU-bound parallelism, Node, Bun, and Deno all have Worker threads (or Web Workers in the browser). They are message-passing — closer to actor model than to Kotlin's Dispatchers.Default. There is no shared class Counter between two workers; you serialise data and post it across.

If your Kotlin code currently relies on Dispatchers.Default for CPU-bound parallel work, that's the part of the move that's lossy. The rest is I/O concurrency, and that translates directly.

Flow ≈ AsyncIterable, not Observable

This is the second place Kotlin developers reach for the wrong analogue. Flow<T> looks reactive. It has map, filter, flatMapMerge, combine, cold semantics by default. The first instinct from a Java background is "this is RxJava with coroutines on top, the JS equivalent must be RxJS". It isn't.

Roman Elizarov's "Reactive Streams and Kotlin Flows" Medium post is a widely cited reference for why Flow was deliberately designed not to be Reactive Streams. The short version: Flow is a suspending sequence. Each terminal operator (collect, toList, first) runs the producer in the calling coroutine. No separate scheduler, no subscription handle, no backpressure protocol — backpressure is "the consumer's collect lambda hasn't returned yet, so the producer is suspended".

The JS equivalent of that exact shape is AsyncIterable<T> / AsyncGenerator<T> plus for await ... of. Cold, lazy, producer suspends until consumer pulls.

fun ticks(): Flow<Int> = flow {
    var i = 0
    while (true) {
        emit(i++)
        delay(100)
    }
}

suspend fun firstFive() {
    ticks().take(5).collect { println(it) }
}
Enter fullscreen mode Exit fullscreen mode
async function* ticks(): AsyncGenerator<number> {
  let i = 0;
  while (true) {
    yield i++;
    await new Promise(r => setTimeout(r, 100));
  }
}

async function firstFive() {
  let count = 0;
  for await (const n of ticks()) {
    console.log(n);
    if (++count === 5) break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Same shape and semantics: the producer suspends at yield/emit until the consumer asks for the next value. Cancellation is the consumer breaking out of the loop. No subscription object to dispose.

When do you reach for RxJS? When you actually want the Observable model — multicasting one upstream to many subscribers, hot UI event streams, time-based operators (debounceTime, throttleTime). Angular shops live in RxJS for legitimate reasons. New Node services and most React/Vue/Svelte apps don't need it; AsyncIterable plus a signal/state library covers what Flow covered. The trap is the JVM developer bringing BehaviorSubject into a Node service for a single-producer single-consumer queue. AsyncGenerator would have done the job.

Cancellation: structured concurrency vs AbortController

This is the gap. Kotlin's structured concurrency is one of the language's better ideas:

suspend fun loadDashboard(id: String): Dashboard = coroutineScope {
    val user    = async { fetchUser(id) }
    val orders  = async { fetchOrders(id) }
    val history = async { fetchHistory(id) }
    Dashboard(user.await(), orders.await(), history.await())
}
Enter fullscreen mode Exit fullscreen mode

If fetchOrders throws, the scope cancels user and history automatically. If the caller's parent scope is cancelled, the whole subtree is cancelled. Cancellation is cooperative (every suspend function checks for it at suspension points), and the token is implicit.

JavaScript's standard answer is AbortController and AbortSignal. The signal is explicit: you create a controller, pass controller.signal to anything that should stop when you call controller.abort().

async function loadDashboard(
  id: string,
  signal: AbortSignal,
): Promise<Dashboard> {
  const [user, orders, history] = await Promise.all([
    fetchUser(id, { signal }),
    fetchOrders(id, { signal }),
    fetchHistory(id, { signal }),
  ]);
  return { user, orders, history };
}

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const dashboard = await loadDashboard("u_42", controller.signal);
Enter fullscreen mode Exit fullscreen mode

AbortSignal is well-supported across runtimes. The fetch API takes a signal option everywhere. setTimeout accepts { signal } in Node 17+ and Bun. Deno's standard library accepts signals throughout. AbortSignal.timeout(ms) and AbortSignal.any([a, b]) are part of the WHATWG DOM standard and shipped in Node 20+, Bun, and Deno. By Node 24 you can plumb a single signal through an entire request lifecycle without writing helpers.

What's missing compared to Kotlin: implicit propagation. In Kotlin, a child coroutine inherits the parent's Job; cancellation flows down for free. In JavaScript, you pass the signal explicitly to every async leaf. Forget one, and that operation keeps running after the request is cancelled.

The pattern that closes the gap is to make the signal the first-class argument on every async boundary in your service. Treat it like Go's ctx context.Context. Every function that does I/O takes a signal, fan-outs pass the same signal down, and libraries you call get it too.

type Ctx = { signal: AbortSignal; userId?: string };

async function fetchUser(ctx: Ctx, id: string): Promise<User> {
  const resp = await fetch(`/users/${id}`, { signal: ctx.signal });
  return resp.json();
}

async function fetchOrders(ctx: Ctx, id: string): Promise<Order[]> {
  const resp = await fetch(`/orders?user=${id}`, { signal: ctx.signal });
  return resp.json();
}

async function loadDashboard(ctx: Ctx, id: string): Promise<Dashboard> {
  const [user, orders] = await Promise.all([
    fetchUser(ctx, id),
    fetchOrders(ctx, id),
  ]);
  return { user, orders };
}
Enter fullscreen mode Exit fullscreen mode

If you want structured-concurrency-style scopes, the closest community work is Effect's Effect system and the TC39 Async Context proposal (Stage 2 as of April 2026), which threads implicit context through await boundaries. Until it ships, the discipline is the same as Go: pass the signal, plumb it through, don't lose the reference.

For "wait for everything to finish or fail", a small helper covers most of what coroutineScope did:

async function scoped<T>(
  fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
  const controller = new AbortController();
  try {
    return await fn(controller.signal);
  } catch (err) {
    controller.abort(err);
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

If anything inside throws, controller.abort(err) cancels the rest (assuming each leaf honours the signal — most modern libraries do). Not as automatic as Kotlin's scope, but the bones are the same.

Quick translation table

Kotlin TypeScript
suspend fun f(): T async function f(): Promise<T>
call f() from a suspend fn await f() inside an async fn
coroutineScope { ... } Promise.all with a shared signal, or a scoped helper
async { ... }.await() await somePromise / Promise.all([...])
withTimeout(5_000) { ... } await fn({ signal: AbortSignal.timeout(5000) })
Job / cancel() AbortController / controller.abort()
Flow<T> cold stream AsyncGenerator<T> + for await
Channel<T> (fan-out queue) AsyncIterable<T> plus a small queue type
StateFlow<T> a signal/observable from your state lib
Dispatchers.IO / .Default event loop; use Worker threads for CPU-bound
runBlocking { ... } top-level await in an ES module

The two real losses are structured-concurrency-by-default and cheap CPU parallelism on Dispatchers.Default. You get the first back with discipline (signal-everywhere), and you get the second back with workers when you actually need it.

The Kotlin habit of "looks synchronous, I trust it" is a good habit. It works in TypeScript too, with the small adjustment that the await is your responsibility, not the compiler's. Once that one thing clicks, the rest of the move is small.

If this was useful

The TypeScript Library is a 5-book collection that maps cleanly to where you start:

  • TypeScript EssentialsAmazon — entry point. Types, narrowing, modules, async, daily-driver tooling.
  • The TypeScript Type SystemAmazon — deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
  • Kotlin and Java to TypeScriptAmazon — bridge for JVM developers. Variance, null safety, sealed→unions, coroutines→async/await.
  • PHP to TypeScriptAmazon — bridge for PHP 8+ developers. Sync→async paradigm, generics, discriminated unions.
  • TypeScript in ProductionAmazon — production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. Books 3 and 4 substitute for 1 and 2 if you're coming from JVM or PHP — this post is a sample of what book 3 covers. Book 5 is for anyone shipping TypeScript at work.

The TypeScript Library — the 5-book collection

Top comments (0)