NOW LET US – AI RAG SaaS Studio TP.HCM
NOW LET US
Digital Product Studio
Back to news
DEV-TOOLS...3 min read

Node.js worker threads are problematic, but they work great for us

Share
NOW LET US Article – Node.js worker threads are problematic, but they work great for us

Node.js's single-threaded nature can lead to Event Loop starvation under CPU pressure. This post explores how Inngest used Worker Threads to isolate critical paths and the specific constraints of Node's threading model.

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. 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. 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.

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.

A synchronous function that runs for 30 seconds blocks everything for 30 seconds. That includes setTimeout callbacks, incoming network data, and any other scheduled work. The timers don't fire at all until the thread is free.

Consider a heartbeat scheduled with setInterval every 10 seconds. The callback is queued, ready to fire. Then a CPU-heavy function starts and runs for 30 seconds straight. The heartbeat callback sits in the timer queue the entire time, and multiple intervals pass without a single one going out. By the time the function returns, the server has already timed out and marked the worker as dead.

What worker threads give you

The worker_threads module lets you spin up additional JavaScript execution contexts within the same process. Each worker gets its own V8 isolate, its own heap, and its own event loop. Critically, one worker's CPU-bound code does not block another worker's event loop.

The constraints

Worker threads solve the isolation problem, but they come with constraints that feel jarring if you've used concurrency primitives in other languages.

You can't pass logic to a worker

In Go, Rust, or Python, you can pass functions or closures directly to a new thread. Node.js worker threads don't work this way. You can't pass a function to new Worker(). The structured clone algorithm, which serializes data between threads, can't serialize functions. Instead, you point the worker at a file.

This means every worker thread is an independent program with its own entry point, imports, and initialization. You design the communication protocol up front and exchange serialized messages, which makes the experience closer to writing a microservice than spawning a concurrent task.

Communication is message passing

Node.js workers are isolated by default. They communicate via postMessage and event listeners. Data is serialized using the structured clone algorithm, meaning most JavaScript values are deep-copied between threads. For small messages this is negligible, but large payloads pay a real cost in both CPU time and memory.

If you need shared state instead of message passing, SharedArrayBuffer lets threads share raw memory, and Atomics provides thread-safe operations on it. This avoids serialization entirely, but you're limited to typed arrays of numbers.

Bundlers can't see your worker file

Bundlers (webpack, esbuild, Rollup) perform static analysis to discover imports. But new Worker("./worker.js") isn't an import; it's a string argument. Modern bundlers recognize specific patterns like new Worker(new URL("./worker.js", import.meta.url)), but any indirection breaks the detection.

© 2026 Now Let Us. All rights reserved.

Source: Hacker News

Advertisement
Ad slot ready: 5887729102

More in this category

EXPLORE TOPICS

Discover All Categories

Deep dive into the specific technology sectors that matter most to you.