Goroutines vs Event Loop: the wrong comparison between two concurrency models

Node.js with the Event Loop and Go with goroutines do not solve the same problem the same way. The common mistake is confusing concurrency with parallelism.

The thesis is simple: Node.js with the Event Loop and Go with goroutines do not solve the same kind of problem the same way. The comparison goes bad when we treat both as direct competitors in every scenario. In practice, the most common mistake is confusing concurrency with parallelism.

Concurrency is organizing several tasks that may be underway at the same time. Parallelism is actually executing work at the same time, using multiple CPU cores. That difference sounds academic until it shows up in production.

The Node.js Event Loop is very good when the bottleneck is waiting: external APIs, databases, WebSockets, user input, queues, and events. While one operation waits for a response, the loop keeps serving other tasks. It is the corner-store owner at the counter: he does not stop because he asked someone to fetch candy from the stockroom.

Goroutines, on the other hand, start looking more interesting when the work is CPU bound, divisible, and can use multiple cores with explicit concurrency control. They are lightweight execution units managed by the Go runtime. With them, a task can be broken into smaller parts, distributed, and synchronized at the end.

It is more like a busy stall at the São João festival in Caruaru: one person grills corn, another stirs canjica, another slices bolo de rolo. Work advances at the same time, each person handling one part.

Where Node.js starts to suffer

I saw this very concretely in a commission-calculation process at a betting company. The application handled millions of bets per day, and part of the flow computed commission over several bet batches.

At first, the Node.js process worked. Sequentially it was correct, but slow. When I tried to parallelize with the usual logic of several tasks at once, the limit appeared: the bottleneck was CPU.

It was not just waiting on database, API, or external events. It was computation over a large bet buffer.

That is the kind of scenario where Promise.all can mislead. It gives a sense of parallelism, but it does not automatically turn heavy CPU work into real parallel execution. If the tasks are compute-intensive and run on the same main thread, the Event Loop stays busy.

The result can be worse than expected: loop blocking, higher latency, worse responsiveness, and more pressure on CPU and memory.

The problem was not Node.js being bad. The problem was using the default Node model for a load that demanded another kind of execution.

Where Go fit better

The solution was rewriting that process in Go with goroutines. The idea was to split the calculation into smaller chunks, process those pieces in parallel, and synchronize only at the end.

That design fit the problem better because the work was CPU bound and divisible. Instead of a centralized flow trying to coordinate several heavy operations, processing became distributed across smaller execution units.

With a worker pool, for example, you can control the goroutine count, limit fan-out, use available cores better, and avoid firing unbounded work.

The gain showed up. Total time dropped about 25%. The process that sat around 30 seconds started running near 22.5 seconds. CPU usage also improved.

But the important part of the story is not “Go fixed it”. The important part is that Go fixed one side of the problem and revealed another.

The bottleneck can move

The first difficulty was guaranteeing the Go result equaled the Node.js result. That is less glamorous than talking about concurrency, but it is what separates real optimization from masked regression.

If the calculation gets faster and changes the financial result, the improvement is worthless.

After that, the main problem became chunk splitting. The initial strategy consumed too much memory. In local tests, with smaller datasets, proportional growth reached near 15% at some points.

The discomfort came from a wrong expectation: I thought moving to Go would automatically solve the problem. In practice, I had only moved the bottleneck. Before, the limit was clearer on CPU. After, the partitioning strategy started pressuring memory.

That can happen for several reasons: unnecessary copies, large buffers, slices holding references to bigger arrays, oversized internal queues, or too much work prepared before being processed.

In the final result, memory still grew about 5%. In that case, the time and CPU gain compensated the loss. But that is not a universal rule. If the production load were much larger, or if the service ran with a thin memory margin, that trade-off could stop being acceptable.

Parallelism costs coordination, allocation, synchronization, and observability. There is no free parallel execution.

When I would keep Node.js

I would keep Node.js without hesitation for I/O orchestration: WebSocket communication, calls to several APIs, database queries, event dispatch, integration between services, and flows where dead time is waiting.

In those cases, the Event Loop is an excellent choice. It allows high volumes of concurrent operations without creating one thread per request. For event-oriented applications, that is simple, productive, and easy to fit into the JavaScript ecosystem.

The mistake is pushing that same model onto heavy computation and thinking I/O concurrency becomes CPU parallelism. It does not.

When I would look at Go

I would start looking at Go when the task is clearly CPU bound: high-volume data computation, batch image processing, heavy aggregations, compression, large buffer transforms, simulations, or any routine where the machine spends more time computing than waiting for external responses.

In that kind of scenario, goroutines with a worker pool give better control over CPU use. They also make the separation between work units, synchronization, and result collection more explicit.

But Go also charges a price. You must think about chunk granularity, memory consumption, cancellation, error handling, backpressure, worker limits, contention, and result consistency.

If the work split is naive, the CPU gain can arrive with memory blowups or needless complexity.

Counterargument: Node.js also has worker threads

There is a fair counterargument: Node.js is not limited to the Event Loop for everything. Worker threads exist precisely to run heavy work outside the main thread. There are also strategies with queues, separate processes, helper services, and native addons.

So the honest comparison is not “Node.js cannot”. It can.

The question is implementation cost, team maturity, observability, integration with the existing system, and how much effort is worth investing to keep that processing inside the Node ecosystem.

On some teams, worker threads may be enough and cheaper than introducing Go. On others, splitting CPU-bound processing into a Go service may be simpler to operate and scale.

The decision should not come from language preference. It should come from the nature of the load.

The practical rule that stuck

After that case, the rule that stuck for me is this: if the problem is waiting on many things at once, Node.js with the Event Loop tends to orchestrate very well. If the problem is computing many things at once, I consider Go with goroutines earlier.

But I also grew suspicious of migrations promising to fix everything. Switching technology may only move the bottleneck.

In my case, CPU and time improved, but memory and chunking had to be reanalyzed.

In the end, the goroutines vs Event Loop fight is less about which model is superior and more about which bottleneck you are trying to attack.

For I/O, the corner-store counter works very well. For CPU, sometimes you really need more people in the festival kitchen.


Originally published on LinkedIn on June 24, 2026.