Home / Articles

Waiting vs Computing: Concurrency and Parallelism in Node.js and Go

Runnable Node.js and Go examples that separate concurrency from parallelism, showing when promises suffice, when you need worker threads, and how goroutines differ.

Publisher: REACTAPP.TOP8 min read1761 words

This article is published in English.

Sources: Concurrency vs parallelism, explained with Node.js and Go

Treating concurrency and parallelism as synonyms leads to real bugs, such as an API that stalls under load. Comparing Node.js and Go makes the difference concrete. Node.js gives you concurrency without any effort and makes you build parallelism yourself, while Go delivers both through a single keyword. With small runnable programs in each language, you will learn which one your workload needs.

Two definitions and a kitchen

Concurrency means one worker handling many tasks by switching to another task whenever the current one is waiting. Parallelism means several workers executing several tasks at literally the same moment.

Picture a kitchen. A single cook with three pans is concurrency: the cook stirs the first pan, and while the second one simmers, chops vegetables for the third. At no instant are two things happening at once, yet the cook is never idle. Put three cooks in the kitchen, one per pan, and you have parallelism.

Node.js: concurrency comes built in

One thread, many requests in flight

Node.js executes your JavaScript on a single thread. It still handles heavy traffic well because most server work is waiting on a database, a file or another service. Node passes that waiting to the operating system (and, for some operations, to libuv's internal thread pool) and continues with the next piece of JavaScript. That scheduling mechanism is the event loop. For a deeper look at the machinery, see how libuv, the event loop and the thread pool fit together.

The following program simulates three requests, each taking one second, using timers wrapped in promises:

function fakeRequest(name, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${name} done`), ms);
  });
}

async function main() {
  console.time("total");

  const results = await Promise.all([
    fakeRequest("users", 1000),
    fakeRequest("orders", 1000),
    fakeRequest("products", 1000),
  ]);

  console.log(results);
  console.timeEnd("total");
}

main();

Because Promise.all starts all three at once and the timers run down simultaneously, console.timeEnd reports roughly one second rather than three. A single thread had three tasks in progress and never sat idle waiting for any of them. That is concurrency.

What happens with real CPU work

The event loop can only overlap waiting. When a task is pure computation, there is nothing to hand off to the operating system. This program sums a large range three times:

function countTo(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
}

console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");

The total comes out at about three times the duration of a single call. Wrapping the calls in Promise.all would not help, because each countTo runs synchronously from start to finish before anything else can run. In kitchen terms, the lone cook has to finish each pan in turn. Worse, in a server the process cannot answer any HTTP request while the loop runs.

Getting parallelism with worker threads

Using more than one CPU core requires more than one thread, and Node's tool for that is the worker_threads module. First, the worker: a separate file that reads its input from workerData, performs the computation and posts the result back to the parent.

// worker.js
const { parentPort, workerData } = require("worker_threads");

let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;

parentPort.postMessage(sum);

The main file wraps each worker in a promise that resolves on the worker's message event and rejects on error, then starts three workers together:

// main.js
const { Worker } = require("worker_threads");
const path = require("path");

function countInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, "worker.js"), {
      workerData: n,
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

async function main() {
  console.time("total");

  await Promise.all([
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
  ]);

  console.timeEnd("total");
}

main();

On a machine with three or more cores, the total drops to about the duration of one call. Three cooks, three pans, working simultaneously. The main thread also stays free to serve requests.

Look at what it took, though: a dedicated file, a message channel and a promise wrapper. In Node.js, parallelism is something you assemble. In production you would also avoid creating a new worker per task, since starting a worker has a real cost; a reusable pool of workers is the usual answer, covered in our guide to worker threads and pools.

Go: one keyword for both

Goroutines instead of an event loop

In Go, prefixing a function call with go starts a goroutine. Each goroutine begins with a stack of only a few kilobytes that grows as needed, so running thousands of them is routine. The Go runtime's scheduler maps goroutines onto operating system threads, and by default it allows as many to execute simultaneously as there are CPU cores, a limit controlled by GOMAXPROCS.

Here is the three-request simulation again, with sync.WaitGroup used to wait until all goroutines have finished:

package main

import (
 "fmt"
 "sync"
 "time"
)

func fakeRequest(name string, wg *sync.WaitGroup) {
 defer wg.Done()
 time.Sleep(time.Second)
 fmt.Println(name, "done")
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for _, name := range []string{"users", "orders", "products"} {
  wg.Add(1)
  go fakeRequest(name, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Each goroutine calls wg.Done() via defer, and wg.Wait() blocks until all are finished. The result matches the Node.js Promise.all version: roughly one second in total.

CPU-bound work needs no extra machinery

Now the counting example. Where Node.js needed a worker file and message passing, the Go version keeps exactly the same shape as before:

package main

import (
 "fmt"
 "sync"
 "time"
)

func countTo(n int, wg *sync.WaitGroup) {
 defer wg.Done()
 sum := 0
 for i := 0; i < n; i++ {
  sum += i
 }
 fmt.Println("sum:", sum)
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for i := 0; i < 3; i++ {
  wg.Add(1)
  go countTo(2_000_000_000, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

On a multi-core machine, this finishes in about the time of a single call. The scheduler found three runnable goroutines and ran them on three threads at once. You expressed the work concurrently, and the runtime turned it into parallel execution.

This captures the distinction that Rob Pike's well-known talk "Concurrency is not parallelism" makes: concurrency is how you structure a program as independently executing pieces, and parallelism is what the runtime can do with that structure when multiple cores are available.

The price of shared memory

Node.js workers each have their own isolated heap and cannot read each other's variables; they communicate by passing messages. (If you truly need shared memory in Node.js, SharedArrayBuffer with Atomics exists, and it brings back the same synchronization concerns described below.)

Goroutines, by contrast, share the same memory. If two goroutines increment one counter without coordination, that is a data race and the final value will be wrong. Go's idiomatic answer is to pass data through channels and to use a mutex only when sharing state is unavoidable.

This worker pool shows the channel approach. Three goroutines read jobs from one channel and write doubled values to another:

package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
 for j := range jobs {
  results <- j * 2
 }
}

func main() {
 jobs := make(chan int, 100)
 results := make(chan int, 100)

 for w := 1; w <= 3; w++ {
  go worker(jobs, results)
 }

 for j := 1; j <= 9; j++ {
  jobs <- j
 }
 close(jobs)

 for r := 1; r <= 9; r++ {
  fmt.Println(<-results)
 }
}

Nothing is shared except the channels themselves, so no locks are needed, and adding workers lets the pool use more cores. The results arrive in whatever order the workers finish, which is expected for this pattern. Running the program with go run -race main.go enables the race detector, which reports any accidental unsynchronized access to shared memory.

The two models compared

  • Unit of concurrency: Node.js uses promises and callbacks scheduled on one event loop; Go uses goroutines.
  • Parallelism: in Node.js it is opt-in, through worker_threads or the cluster module (which runs multiple processes); in Go it is on by default, with the scheduler using every core.
  • CPU-heavy work: in Node.js it blocks the event loop unless you move it to a worker; in Go it is simply another goroutine.
  • Sharing data: Node.js workers exchange messages; goroutines use channels or mutex-protected shared memory.
  • Typical failure mode: in Node.js, a blocked event loop; in Go, data races.

Choosing the right tool: waiting or computing?

A single question settles most cases: is the slow part of your workload waiting, or computing?

If it is waiting (on a database, the filesystem or a network call), concurrency is all you need. In Node.js that means promises and async/await. In Go it means goroutines, and the scheduler parks the ones that are blocked so they consume no CPU.

If it is computing (resizing images, hashing, parsing a very large file), you need parallelism. In Node.js, move the work to worker threads, or use cluster to spread request handling across processes. In Go, launch goroutines and make sure they exchange data through channels or protect shared state with a mutex.

Parallelism is capped by hardware: on a single-core container, three CPU-bound tasks run no faster than sequentially in either language.

Key takeaways

  • Concurrency is about structure: arranging work so nothing sits idle while something waits. Parallelism is about hardware: how many things truly run at the same time.
  • In Node.js, Promise.all overlaps waiting but does nothing for synchronous computation, which blocks every request on the thread.
  • Node.js parallelism requires explicit worker threads (ideally pooled) or multiple processes.
  • Go's scheduler turns concurrent goroutines into parallel execution automatically, but shared memory makes data races the thing to guard against; prefer channels and use the race detector.