Worker threads solve real problems, but they come with constraints that Go, Rust, and Python developers would never expect. Here's what we learned moving Inngest Connect's internals off the main thread.
Node.js runs on a single thread. That's usually fine. The event loop handles I/O concurrency without you thinking about locks, races, or deadlocks. But "single-threaded" has a cost that only shows up under pressure: if your JavaScript monopolizes the CPU, nothing else runs. No timers fire. No network callbacks execute. No I/O completes.
We ran into this with Inngest Connect, a persistent WebSocket connection between your app and the Inngest server. Connect is an alternative to our HTTP-based model (our serve function) that reduces TCP handshake overhead and avoids long-lived HTTP requests during long-running steps. Workers send heartbeats over the WebSocket so the server knows they're alive.
The problem: Users reported "no available worker" errors despite their workers running.
Users reported "no available worker" errors despite their workers running. The cause: CPU-heavy user code was monopolizing the main thread, starving the event loop, and blocking heartbeats. The server assumed the workers were dead and stopped routing work to them.
CPU-heavy user code was monopolizing the main thread, starving the event loop, and blocking heartbeats. The server assumed the workers were dead and stopped routing work to them. The fix: Move Connect internals into a worker thread.
But getting there taught us a few things about how worker threads actually work in Node.js, and how they compare to threading models in other languages.
Connect's worker thread isolation is a new feature in v4 of the Inngest TypeScript SDK. Upgrade to get it automatically. This post focuses on Node.js, but Bun and Deno also support worker threads.
Event loop starvation
Node's event loop processes callbacks in phases: timers, I/O polling, setImmediate , close callbacks. Between each phase, it checks for microtasks (resolved promises, queueMicrotask ). The critical property: the loop can only advance when the current JavaScript execution yields.
... continue reading