js-routines
Go-style concurrency for TypeScript: channels, select, synchronization primitives, cancellation and a deterministic scheduler for testing concurrent code.
- TypeScript
- Bun
- Node.js
Challenge
A read, an await and a write are not atomic even on one thread: with 1,000 routines adding 1 that way, the result without a mutex was 1. On the event loop, whoever wakes first runs first, so a race that depends on a specific order shows up in production and never in tests, and a deadlock is not an error: the Promises stay pending and the process just ends.
Approach
Follow Go's runtime: channels keep the structure of Go's hchan, with a ring buffer and queues of waiting senders and receivers; select follows selectgo and tries ready cases in random order; the Semaphore follows x/sync/semaphore with strict FIFO, and the Mutex is a Semaphore(1). Blocking means keeping a Promise's resolve in a wait queue, and waking means calling it. Every blocking operation accepts an AbortSignal. In tests, a seed decides which routine runs at each step, deadlocks are reported with what each routine was waiting for, and the clock only moves when every routine is blocked. Equivalent Go and TypeScript programs are run and their outputs compared.
Outcome
explore runs a program under many seeds and reports the first that fails: it found a race that shows up in 4.6% of runs, and that seed reproduces it every time. Virtual time simulates 24 hours in 1 ms. Across threads, 4 workers ran CPU work 3.76x faster where 4 async routines gave 0.99x, and the same queue without a lock lost 145,000 of 200,000 messages, which is why the shared channel keeps Go's lock. Tested on Bun 1.3 and Node 26, and not yet published on npm.
Architecture
Decisions and trade-offs
select runs exactly one operation, unlike Promise.race
With Promise.race every operation starts and the losing ones still take effect. select tries the cases, parks on all of them and, once woken, leaves the other queues.
A ready case is picked at random
Ready cases win with probability 1/k, so an always-ready channel cannot hide quit: select notices it in 2 tries on average.
There is no priority: code that must always check quit first does it as in Go, with one select holding only quit and default, then the normal one.
The channel buffer is a ring buffer, not an array
Array.shift is quadratic in V8: 620 ms against 1.2 ms for 100,000 items on Node 26.
Cancellation uses AbortSignal
It is the platform's standard, so the same signal cancels a recv and a fetch. A blocked operation whose signal fires leaves its queue and rejects with the signal's reason.
In tests, every yield* is a scheduling point
Switching only when a routine blocks, as Go does, reached 2 of 20 orders and stopped finding races between operations that do not block.
About 1.9x less throughput than switching only when blocked, which stays available as an option.
The Promise API skips the scheduler's Operation on its fast path
Putting Operation on the fast path of send and recv cost about 13% of throughput, so the Promise API tries the operation directly and only parks when it has to.
Key evidence
A seed reproduces every time a race that shows up in 4.6% of runs
