Go Concurrency for Node Developers Who Know Promises
Translate promises and async await into goroutines and channels with runnable Node and Go pairs: all, race, timeouts, event loop blocking and limits.

Node developers arrive in Go with a good intuition for I/O concurrency: start several operations, don't block on any of them, handle the results as they arrive. The vocabulary is different (goroutines and channels instead of promises and callbacks), and one fundamental assumption changes: Node runs your JavaScript on one thread, while Go runs your goroutines on all of them. Most of what is new is a consequence of that.
Tested with Go 1.25.6, Node 24, macOS on Apple Silicon (arm64)
This guide translates the common promise patterns into Go with runnable pairs, and then covers the differences that matter in practice: a CPU loop that does not freeze everything, bounded concurrency you must write yourself, and shared state that needs protection. It connects to goroutines, channels, select and context, and sits in the concurrency patterns cluster. For the Python equivalent, see Go concurrency for Python developers. Everything ran on Go 1.25.6 and Node 24.
No event loop, no async
In Node, any function that waits returns a promise or takes a callback, and you mark the callers async and await them. The event loop interleaves everything on a single thread. There is no equivalent in Go because there is no special kind of waiting function. os.ReadFile, http.Get and time.Sleep block the goroutine that calls them, and the Go runtime runs other goroutines meanwhile on any free core. To do something concurrently, you start a goroutine with go:
go sendEmail(user) // runs concurrently; the caller does not wait for itThat is the entire syntax for "don't await this". The corollary is that there is no function coloring: a function that does I/O has the same signature as one that does arithmetic, and nobody downstream needs to change when you add I/O to it.
Promise.all becomes a WaitGroup
Three simulated requests with different delays, started together and collected in order, in Node and in Go:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function fetchData(name, ms) {
await sleep(ms);
console.log(`${name} done after ${ms / 1000}s`);
return name.toUpperCase();
}
async function main() {
const start = Date.now();
const results = await Promise.all([fetchData('users', 300), fetchData('orders', 100), fetchData('stats', 200)]);
console.log(results, `total ${((Date.now() - start) / 1000).toFixed(1)}s`);
}
main();orders done after 0.1s
stats done after 0.2s
users done after 0.3s
[ 'USERS', 'ORDERS', 'STATS' ] total 0.3spackage main
import (
"fmt"
"strings"
"sync"
"time"
)
func fetchData(name string, d time.Duration) string {
time.Sleep(d)
fmt.Printf("%s done after %.1fs\n", name, d.Seconds())
return strings.ToUpper(name)
}
func main() {
start := time.Now()
jobs := []struct {
name string
d time.Duration
}{{"users", 300 * time.Millisecond}, {"orders", 100 * time.Millisecond}, {"stats", 200 * time.Millisecond}}
results := make([]string, len(jobs)) // one slot per job keeps the order, like Promise.all
var wg sync.WaitGroup
for i, j := range jobs {
wg.Go(func() { results[i] = fetchData(j.name, j.d) })
}
wg.Wait()
fmt.Println(results, fmt.Sprintf("total %.1fs", time.Since(start).Seconds()))
}orders done after 0.1s
stats done after 0.2s
users done after 0.3s
[USERS ORDERS STATS] total 0.3sSame output: the tasks finish in order of their delay, the results come back in request order, and the total is about 0.3 seconds. The Go version has no promise object; the result slice is the "future", and wg.Wait() is the await Promise.all. When a task can fail, errgroup is the closer match: like Promise.all, its Wait returns the first error, and with errgroup.WithContext the remaining tasks are cancelled (errgroup). Promise.allSettled, which wants every outcome, is a result slice where each element carries its own error, as in parallel HTTP requests.
Promise.race and timeouts become select
Promise.race between a slow operation and a timeout is the standard JavaScript timeout idiom. Go's select waits on several channels and takes whichever is ready first, so the same idea is a few lines:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function slowQuery() {
await sleep(2000);
return 'rows';
}
async function main() {
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), 200));
try {
console.log(await Promise.race([slowQuery(), timeout]));
} catch (err) {
console.log('main():', err.message);
}
}
main();main(): timed outpackage main
import (
"context"
"fmt"
"time"
)
func slowQuery(ctx context.Context) (string, error) {
select {
case <-time.After(2 * time.Second):
return "rows", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if _, err := slowQuery(ctx); err != nil {
fmt.Println("main():", err)
}
}main(): context deadline exceededThere is a real difference in what happens to the loser. In the JavaScript version, Promise.race returns when the timeout rejects, but slowQuery() keeps running to completion; JavaScript has no way to stop it, and a promise cannot be cancelled. AbortController and AbortSignal exist to fix this, but each API has to accept a signal and honor it. Go's context is the same idea made universal: the context travels as the first argument of every function that can block, the standard library respects it (http.NewRequestWithContext, db.QueryContext), and when it ends the work stops at the next check (context cancellation, timeouts).
The event loop is shared; goroutines are not
This is the largest difference in behavior, and a measurement shows it better than a sentence. A timer is set for 10 ms. Meanwhile some code spins the CPU for 200 ms. In Node, the timer callback cannot run until the loop is free, so it fires about 200 ms after it was due:
const scheduled = Date.now();
setTimeout(() => {
console.log(`node: timer due in 10 ms fired after ${Date.now() - scheduled} ms`);
}, 10);
const end = Date.now() + 200;
while (Date.now() < end) {} // 200 ms of CPU work on the only threadnode: timer due in 10 ms fired after 200 msThe Go version starts the same spinning work in one goroutine and a timer in another. They run on different cores, so the timer keeps time:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Go(func() { // 200 ms of CPU work on one goroutine
end := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(end) {
}
})
wg.Go(func() { // a timer on another goroutine, due in 10 ms
scheduled := time.Now()
time.Sleep(10 * time.Millisecond)
fmt.Printf("go: timer due in 10 ms fired after %d ms\n", time.Since(scheduled).Milliseconds())
})
wg.Wait()
}go: timer due in 10 ms fired after 11 msIn Node the 10 ms timer waited behind the loop and fired after 200 ms; in Go it fired after 11 ms, close to on time, because the spinning goroutine occupies one core and the timer goroutine runs on another. This is the practical meaning of "Go is multithreaded": a slow request handler, a JSON parse of a large body or an accidental infinite loop affects its own goroutine and a share of one core, not every other connection. The flip side of the coin is in the next section. And it is why the Node pattern of "never block the event loop" does not carry over: in Go, computing in a request handler is normal.
Node's answer to CPU-bound work is worker_threads or a child process, with message passing between them. The Go equivalent is just more goroutines, and the number to watch is GOMAXPROCS, the number of cores Go uses (concurrency vs parallelism).
What you gain, and what you must now do
Because JavaScript runs on one thread, count++ in a callback is always safe: no other callback can run between the read and the write. Node developers rely on that without noticing. In Go, goroutines run in parallel, and the same statement from several goroutines is a data race that silently loses updates (data races). The usual tools are a mutex around the shared data, an atomic for a single number, a channel to pass ownership to one goroutine, or a result slice where each goroutine writes its own element, as in the example above. Run your tests with go test -race; it is the habit that replaces the safety the event loop gave you for free (the race detector).
Unbounded by default
await Promise.all(items.map(async (item) => ...)) starts every task at once. With a thousand items that might be fine; with a million it exhausts file descriptors or hammers a downstream service. JavaScript programmers add p-limit or a hand-written queue. Go has the same problem and the same fix, written with a buffered channel as a semaphore or with errgroup.SetLimit. The counterparts:
sem := make(chan struct{}, 10) // at most 10 in flight, like p-limit(10)
var wg sync.WaitGroup
for _, item := range items {
sem <- struct{}{}
wg.Go(func() {
defer func() { <-sem }()
process(item)
})
}
wg.Wait()A loop that blocks when ten tasks are running is the whole mechanism. For error handling and cancellation, errgroup with SetLimit(10) does it in fewer lines (bounded parallelism, SetLimit).
Errors, rejections and panics
A rejected promise nobody handles produces an unhandledRejection, which in modern Node terminates the process by default, but lets older code log a warning and continue. In Go, errors are ordinary return values, and a goroutine cannot throw to its starter; the failure goes into a channel, a result slot or an errgroup. The thing that does terminate the program is a panic in any goroutine that nobody recovers: the whole process exits with a stack trace. Servers recover at the top of goroutines that run untrusted work (recovering in goroutines, error handling).
Worker threads versus goroutines
When Node developers hit CPU-bound work they reach for worker_threads, and the shape of that API shows how different the models are. A worker is a separate JavaScript isolate with its own heap. You start it from a file, send it data with postMessage (copied by the structured clone algorithm, or transferred for buffers), and receive results by message. Sharing memory takes SharedArrayBuffer and Atomics. It is isolation by default, with sharing as an explicit, awkward opt-in.
Goroutines are the reverse: one address space, sharing by default. A goroutine can read a slice that another goroutine wrote, with no copying and no serialization, and that is why parallel code is so cheap to write in Go: partition a slice into chunks, give each goroutine a sub-slice, and wait (partitioning an array). The price is that the language no longer prevents two goroutines from touching the same variable, which is the burden of the previous section. If you find yourself missing the isolation, the closest Go habit is to make goroutines communicate over channels and keep each piece of data owned by one goroutine at a time (sharing memory by communicating). A worker that receives messages, owns its state and replies on a channel is the actor pattern, and it is what a Node worker looks like when written in Go (the actor model).
Async iteration and channels
for await (const x of source()) consumes a stream of values that arrive over time, and an async generator produces them. In Go the stream is a channel, the producer is a goroutine that sends on it, and the consumer is a plain for range. Both sides below produce three values 50 ms apart and print them as they arrive:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function* ticks(n) {
for (let i = 1; i <= n; i++) {
await sleep(50);
yield i;
}
}
(async () => {
for await (const t of ticks(3)) console.log('received', t);
console.log('stream ended');
})();received 1
received 2
received 3
stream endedpackage main
import (
"fmt"
"time"
)
func ticks(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // closing is how the consumer's loop learns the stream ended
for i := 1; i <= n; i++ {
time.Sleep(50 * time.Millisecond)
ch <- i
}
}()
return ch
}
func main() {
for t := range ticks(3) {
fmt.Println("received", t)
}
fmt.Println("stream ended")
}received 1
received 2
received 3
stream endedThe output is identical. The Go version has one more responsibility: the producer is a goroutine that outlives the call to ticks, so a consumer that stops early must tell it to stop, normally with a context, or the goroutine leaks blocked on its send. In JavaScript, break inside for await calls the generator's return() method, which stops the producer automatically. Generators built with goroutines covers the leak, and since Go 1.23 an iter.Seq gives you the generator behavior with automatic cleanup on break for the single-goroutine case (iterators instead of channels).
Shutdown
A Node service listens for signals with process.on('SIGTERM', ...), closes the HTTP server and exits when the loop is empty. Go does the same job with signal.NotifyContext and Server.Shutdown, and then you wait for your goroutines, because the process does not exit "when there is nothing left to do" the way a drained event loop does: it exits when main returns, whatever else is running (graceful shutdown, handling SIGTERM). That is the reverse of Node's default, and a frequent source of lost work in first Go services.
Debugging follows the same split. In Node you inspect a single thread with the inspector and look at the async stack of a promise chain. In Go a hang is diagnosed from a goroutine dump, which lists every goroutine and what it waits for, and a slow request from a trace (goroutine dumps, the execution tracer). Both are built into the toolchain, so there is nothing to install before the first outage.
Equivalents at a glance
| Node.js | Go |
|---|---|
async function / await | an ordinary function, blocking inside |
fn() without awaiting | go fn() |
Promise.all | sync.WaitGroup, errgroup |
Promise.race / timeout | select with time.After or ctx.Done() |
AbortController | context.Context |
setTimeout, setInterval | time.AfterFunc, time.NewTicker |
EventEmitter | a channel, or a small broker |
for await ... of | for ... range ch |
worker_threads | goroutines |
p-limit | buffered-channel semaphore, errgroup.SetLimit |
process.on('unhandledRejection') | recover at the top of a goroutine |
Events and streams
EventEmitter maps to a channel when there is one consumer and one stream of events, and to a small pub/sub when there are many (pub/sub with channels, though most event buses are too clever). Node streams with backpressure map to io.Reader and io.Writer for byte streams, where a blocking Write is the backpressure, and to bounded channels for objects. for await (const x of stream) is for x := range ch, or for x := range seq when you use the iterator functions added in Go 1.23 (iterators instead of channels).
Mistakes I see when Node developers start
Wrapping everything in a goroutine plus a channel to mimic a promise. A function that returns <-chan Result where Result has a value and an error is a promise rebuilt by hand. Return (Result, error) and let the caller decide whether to run it concurrently (when not to use goroutines).
Forgetting that main ends the program. Starting a goroutine and returning from main kills it. In Node the process stays alive while work is pending; in Go nothing keeps it alive but your code.
Expecting callbacks to be serialized. Two HTTP handlers run at the same time on different cores. A package-level var cache = map[string]string{} that was fine in Express is a crash in net/http. Protect shared state or keep it behind a goroutine (concurrent map writes).
Loops that close over the loop variable. Before Go 1.22 a closure inside a for loop saw one shared variable, as var did in old JavaScript. Modules with go 1.22 or later give each iteration its own variable, like let (loop variables).
A first exercise
Take a small Node script that makes a few HTTP calls with Promise.all, and port it. Use net/http with a Client{Timeout: ...}, a goroutine per call, a result slice, and -race. Then read the concurrency section of Effective Go and the Go blog post on context. The MDN reference for promises is a good companion while you translate the combinators.


