· 6 min read ·

The Case for Copying Tokio: Antiox and TypeScript's Async Concurrency Gap

Source: lobsters

Most TypeScript tutorials treat async/await as solved infrastructure. For simple CRUD handlers hitting a database, that assessment holds. Once you’re building backend systems with concurrent task coordination, inter-component messaging, timeouts, and cancellation, the gaps become apparent in ways that are difficult to paper over.

The team at Rivet shared their reasoning on Lobsters alongside releasing Antiox, a library that ports Tokio’s concurrency primitives to TypeScript. The core observation is blunt: async concurrency bugs were their most frequent issue in TypeScript, and the same bugs didn’t materialize in their Rust code. The difference wasn’t discipline or experience. It was the available primitives.

Why Rust’s Model Produces Fewer Bugs

Tokio is built around a few compounding properties. Tasks are cheap to spawn, so you can model concurrent work as actual concurrent tasks rather than coordinating everything through shared state. Communication between tasks happens through typed channels, which are explicit about direction and ownership. And every async operation in Rust is lazy by default, meaning it doesn’t begin executing until something polls it.

That laziness is load-bearing. Because futures don’t run until polled, you can cancel them by simply dropping the future. No cleanup callback, no cancellation token threading, no flag checking. The runtime handles it. tokio::select! exploits this directly: it polls multiple futures simultaneously and drops the branches that didn’t complete first.

use tokio::sync::mpsc;

let (tx, mut rx) = mpsc::channel::<WorkItem>(32);

tokio::spawn(async move {
    while let Some(item) = rx.recv().await {
        process(item).await;
    }
});

tx.send(WorkItem::new()).await?;

This pattern is straightforward to the point of being boring. The channel enforces a single, explicit path for data to move between tasks. Backpressure is built in through the bounded buffer. The worker exits cleanly when the sender is dropped. There’s no shared state to reason about and no race condition to accidentally introduce.

Tokio also gives you oneshot channels for single-value communication (useful for request/response patterns between tasks), broadcast for fan-out, and watch for sharing a single value with multiple readers who only care about the latest. Each channel type corresponds to a specific communication pattern, which means you reach for the right tool based on what you’re modeling rather than adapting a general-purpose mechanism to every case.

The Fundamental Promise Problem

JavaScript Promises are eager. The moment you write const p = someAsyncOperation(), the operation starts. There is no mechanism to cancel it afterward. Promise.race doesn’t cancel the losing competitors. Promise.all doesn’t cancel remaining tasks when one fails. You end up with operations that continue running even when no part of your application is listening for the result.

// The slow fetch continues running after timeout fires
const result = await Promise.race([
  fetch('/api/slow-endpoint'),
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
]);

This isn’t a bug in the language; it’s a consequence of how the V8 event loop is designed. JavaScript’s concurrency model is push-based. Once a microtask is scheduled, it runs. There is no cooperative polling mechanism that would allow a runtime to preempt or cancel in-flight work the way Tokio can drop an unpolled future.

The ecosystem’s answer to this is AbortController and AbortSignal, which arrived in Node.js 15 and are now supported across all modern runtimes. You create a controller, pass its signal to operations that respect it, and call controller.abort() when you want to stop. The fetch API supports it natively. Most Node.js stream operations do too.

The problem is threading abort signals through application code by hand. Every function in the call chain that performs cancellable work needs to accept and forward the signal. It’s correct when done properly, but it requires discipline to maintain and is easy to drop at any layer.

What Antiox Provides

Antiox translates Tokio’s primitive set into TypeScript as directly as the language allows. The library ships mpsc and oneshot channels, a select combinator, timeout utilities, mutex, and semaphore. The API mirrors Tokio closely enough that developers familiar with one will recognize the other immediately.

import { channel } from "antiox/sync/mpsc";
import { oneshot, OneshotSender } from "antiox/sync/oneshot";

// Buffered channel with capacity 10
const [tx, rx] = channel<string>(10);

// Iterate over incoming messages
(async () => {
  for await (const msg of rx) {
    await handleMessage(msg);
  }
})();

await tx.send("hello");
await tx.send("world");
tx.close();

The oneshot channel covers a pattern that’s genuinely awkward with raw Promises: waiting for a single response from a separate component without coupling the two sides through shared state.

const [sender, receiver] = oneshot<ProcessResult>();

// Hand the sender to a worker component
workerPool.submit(task, sender);

// Block until the result arrives
const result = await receiver.recv();

For cancellation, Antiox automatically creates and threads AbortSignal instances wherever Tokio would rely on future dropping. When you use select to race operations, the branch that loses receives an abort signal. The library can’t cancel a Promise by force, but it can make the cancellation contract consistent and automatic, which is what the manual AbortController pattern requires you to maintain yourself.

The library is explicit about the scope of what it’s solving. It targets Tokio’s single-threaded scheduler equivalent, not multi-threading via worker pools. That scope is right for most backend use cases. The concurrency bugs Rivet described are about coordination and communication failures, not about saturating CPU cores.

Why Copy Instead of Design

Most concurrency library authors in the TypeScript ecosystem try to find the idiomatic JavaScript equivalent of each concept. The results are often libraries that feel slightly off to everyone: too unfamiliar for JavaScript-first developers, subtly wrong for developers expecting Rust semantics. The translation introduces new design decisions at every step, and each one is an opportunity for something to be subtly wrong.

Antiox takes the opposite position: copy Tokio identically, then work around what TypeScript can’t do. This is a stronger bet than it might look. Tokio’s API has been in production for years, refined against real workloads, and the current shape reflects hard-won knowledge about what patterns actually matter. Backpressure semantics in mpsc, the separation of oneshot and multi-message channels, the exact behavior of select when multiple branches are ready simultaneously, these details exist because someone hit problems without them.

Inheriting that design work without modification means inheriting the lessons embedded in it. The library doesn’t have to be right about API design; it just has to faithfully port something that already is.

Comparing the Alternatives

The closest philosophical relative is Effection, which brings structured concurrency to JavaScript. Effection models async work as a task tree where child tasks are automatically cancelled when their parent scope exits. This addresses the cancellation problem more fundamentally than abort signals do; lifetime management becomes structural rather than manual. The trade-off is that Effection asks you to commit to its model from the architecture level up. It’s not a set of primitives you add to existing code; it’s a way of organizing async programs.

p-queue solves concurrency limiting well within its narrow scope. async-mutex provides a usable mutex. RxJS covers reactive streams comprehensively but operates on a different paradigm entirely, one where streams and operators replace the channel and task vocabulary. These libraries are good at what they do, but none of them gives you the full vocabulary needed for the backend coordination patterns that produce the bugs Rivet described.

For teams that write both Rust and TypeScript, Antiox’s value is the reduction in context switching. The channel patterns, the oneshot for request/response, the select for racing timeouts against work, they read the same way in both languages. That shared vocabulary reduces the translation overhead when moving between codebases or when a developer more fluent in one language is reading code in the other.

What It Doesn’t Fix

Abort signals only help when the code on the other end checks them. Third-party libraries that don’t support AbortSignal will continue to run regardless of what Antiox does. The event loop is still cooperative; synchronous CPU work or uncooperative I/O blocks everything. These are real limitations, and they’re baked into the JavaScript runtime rather than anything Antiox could address.

The library is also new enough that the edge cases haven’t been discovered in production yet. Tokio earned its reputation through years of use; Antiox is starting that journey now. The channel implementations, the select semantics under load, the behavior when tasks panic, these will be tested as more code depends on the library.

For backend TypeScript with real concurrency requirements, though, having Tokio’s channel vocabulary available is a meaningful improvement over coordinating through shared mutable state and hoping Promise.all is sufficient. The library doesn’t solve every problem the language has, but the problems it targets are the right ones.

Was this interesting?