from async/await to go's concurrency

TypeScript event loop one thread await fetch A await fetch B take turns on one stack to Go main go / go / go g1 g2 g3 channel run together, pass values
Same job: fetch a few things. Two different machines underneath.

If you write TypeScript for a living, concurrency already feels familiar. You await a fetch, fan out with Promise.all, race with Promise.race, cancel with an AbortController. Then you open a Go tutorial and the page is covered in words that don't map cleanly: goroutine, channel, WaitGroup, select, mutex, context, panic, defer.

This post is the map I wish I'd had. Not a language tour. Just the concurrency model, translated from a TypeScript brain, with the jargon attached to something you already know.

The thing that actually changes

In Node, your JavaScript runs on one thread. When you await, you aren't starting a second worker. You're yielding the event loop so it can run someone else's callback while the OS finishes your I/O. That's concurrency without parallelism. CPU-heavy work still blocks everything unless you move it to a Worker Thread or another process.

Go is different on purpose. A goroutine is a function the Go runtime can run alongside others, scheduled across real OS threads. Two goroutines doing CPU work can genuinely run at the same time on two cores. You don't opt into a thread pool. You write a normal function and put go in front of the call.

Rob Pike's line is the whole design:

Don't communicate by sharing memory; share memory by communicating.

In TypeScript you usually share state (a closed-over variable, a Redux store, a module-level Map) and hope everyone awaits in the right order. In Go the preferred pattern is to hand data off through a channel, so only one goroutine owns it at a time.

Goroutines

A goroutine is a lightweight concurrent function. Starting one is one keyword:

go fetchUser(id)

That's closer to firing a Promise without awaiting it than to spinning up a thread:

void fetchUser(id); // floating promise: you hope something waits

A few things that surprise people coming from TypeScript:

main go fetchA() go fetchB() go fetchC() all three run alongside main
`go` is the entire launch API. No Promise constructor, no thread pool config.

One trap that has no TypeScript equivalent: loop variables. This classic bug captures the same url for every goroutine:

for _, url := range urls {
    go func() {
        fetch(url) // all of them may see the last url
    }()
}

Pass it in as an argument so each goroutine gets its own copy:

for _, url := range urls {
    go func(u string) {
        fetch(u)
    }(url)
}

(Modern Go, 1.22+, fixed the loop-variable capture in for loops. Still pass arguments: it's clearer, and you'll read older code forever.)

WaitGroup: your Promise.all

go alone is fire-and-forget. If main returns, the whole program exits and your goroutines die mid-flight. You need something that waits.

sync.WaitGroup is the blunt tool that maps to Promise.all when you don't care about the return values, only that everything finished:

var wg sync.WaitGroup
 
for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fetch(u)
    }(url)
}
 
wg.Wait() // blocks until the counter hits zero

The TypeScript version you already write:

await Promise.all(urls.map((u) => fetch(u)));

The rules that matter:

Call When Like
wg.Add(1) before go, in the parent pushing onto the promise array
wg.Done() when the goroutine finishes (usually defer) the promise settling
wg.Wait() in the parent, after all launches await Promise.all(...)

defer is another piece of jargon worth naming here: defer f() schedules f to run when the surrounding function returns, even on early return or panic. defer wg.Done() is the Go equivalent of a finally that decrements the counter no matter how the goroutine exits.

WaitGroup counter: 3 → 0 goroutine A goroutine B goroutine C each Done() decrements · Wait() blocks until zero
Same shape as `Promise.all`: launch N, wait until N settle.

Channels: typed pipes, not Promises

A WaitGroup only says "done". A channel moves values between goroutines. Think of it less like a Promise and more like a typed queue that can also synchronize the sender and receiver.

ch := make(chan string) // unbuffered
 
go func() {
    ch <- "hello" // send: blocks until someone receives
}()
 
msg := <-ch // receive: blocks until someone sends
Go Rough TypeScript feeling
make(chan T) a one-shot inbox for values of type T
ch <- v "here's a value" (may wait for a taker)
v := <-ch await the next value
make(chan T, n) a buffered queue of capacity n
close(ch) "no more values are coming"
for v := range ch for await (const v of …) until closed

Unbuffered channels are a handshake: the sender blocks until the receiver is ready, and vice versa. That handshake is the synchronization. Buffered channels let the sender get ahead by up to n values before blocking.

Only the sender should close a channel, and only once. Sending on a closed channel panics. Receiving from a closed channel yields the zero value and ok == false, which is how range knows to stop.

results := make(chan int, len(nums))
 
for _, n := range nums {
    go func(n int) {
        results <- n * n
    }(n)
}
 
for i := 0; i < len(nums); i++ {
    fmt.Println(<-results)
}

That's the Go shape of "map async, collect results", closer to a worker pool's output queue than to Promise.all returning an array.

producer chan T the pipe consumer ch <- v v := <-ch ownership moves with the value
The channel is both the transport and the lock. Hand off the data; don't share it.

select: Promise.race with cases

select waits on several channel operations and runs whichever is ready first. If several are ready, it picks one at random.

select {
case msg := <-ch1:
    fmt.Println("from ch1:", msg)
case msg := <-ch2:
    fmt.Println("from ch2:", msg)
case <-time.After(time.Second):
    fmt.Println("timed out")
}
TypeScript Go
Promise.race([a, b]) select over receives from chA, chB
AbortSignal timeout case <-time.After(d):
non-blocking check select with a default: case

time.After returns a channel that receives once after the duration. Putting it in a select is the idiomatic timeout. The default case makes the select non-blocking: useful for "send if anyone is listening, otherwise skip".

Mutexes: when you do share memory

Channels are preferred, not mandatory. Sometimes a shared counter or a cache is clearer with a lock. That's sync.Mutex (and sync.RWMutex when many readers / few writers).

var (
    mu    sync.Mutex
    count int
)
 
mu.Lock()
count++
mu.Unlock()

In TypeScript, single-threaded JS means a synchronous count++ can't race with itself. In Go, two goroutines incrementing without a mutex will race, and the race detector (go test -race) will tell you.

Rule of thumb I use: if the data has a clear owner and a direction of flow, use a channel. If many goroutines need to poke the same small piece of state, use a mutex. Don't mix both on the same data.

Context: AbortController for the whole tree

context.Context carries deadlines, cancellation signals and request-scoped values down a call chain. Almost every I/O function in the standard library and in good third-party code takes ctx as the first argument.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
 
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
TypeScript Go
AbortController / AbortSignal context.WithCancel
AbortSignal.timeout(ms) context.WithTimeout / WithDeadline
checking signal.aborted ctx.Done() channel, or ctx.Err()
passing signal into fetch passing ctx into NewRequestWithContext

When cancel() is called (or the deadline passes), ctx.Done() closes. Anything selecting on it can bail out. Always defer cancel() so the timer and resources get cleaned up even if you return early.

A small program that uses most of it

Fetch a few URLs concurrently, keep the first successful body, give up after two seconds:

func firstOK(urls []string) (string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
 
    type result struct {
        body string
        err  error
    }
    ch := make(chan result, len(urls))
 
    for _, url := range urls {
        go func(url string) {
            req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
            if err != nil {
                ch <- result{err: err}
                return
            }
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                ch <- result{err: err}
                return
            }
            defer resp.Body.Close()
            b, err := io.ReadAll(resp.Body)
            ch <- result{body: string(b), err: err}
        }(url)
    }
 
    var lastErr error
    for range urls {
        select {
        case <-ctx.Done():
            return "", ctx.Err()
        case r := <-ch:
            if r.err == nil {
                cancel() // stop the losers
                return r.body, nil
            }
            lastErr = r.err
        }
    }
    return "", lastErr
}

Reading it with a TypeScript lens:

  1. WithTimeout ≈ AbortSignal.timeout(2000) shared by every fetch.
  2. go func... ≈ launching a Promise per URL without awaiting yet.
  3. Buffered ch ≈ collecting settlements into an array you'll read from.
  4. select on ctx.Done() vs ch ≈ Promise.race between a timeout and the next result.
  5. cancel() after the first success ≈ aborting the remaining fetches.

You could have used a WaitGroup here too. You don't need one, because the receive loop already waits for len(urls) results (or a timeout). WaitGroups shine when there's nothing to collect: "fan out these side effects, then continue".

Jargon cheat sheet

You say in TypeScript They say in Go What it really is
event loop scheduler (GMP) runtime that multiplexes goroutines onto OS threads
async function plain function + go concurrency at the call site
floating Promise goroutine concurrent work with no waiter yet
await Promise.all WaitGroup.Wait or drain a channel wait for a known set to finish
await promise v := <-ch block until a value arrives
Promise.race select proceed with the first ready case
AbortController context.Context cancel / deadline propagated down the stack
finally { } defer run on the way out, no matter what
throw panic unwind; recover with recover() (use sparingly: prefer error)
try/catch on errors if err != nil errors are values, not exceptions
Worker Thread OS thread (P/M in the scheduler) you rarely touch these directly
shared module state mutex-guarded data or channel ownership prefer handoff over sharing
null / undefined zero value ("", 0, nil) every type has a usable default
npm / package.json modules (go.mod) go get, semantic import paths
tsconfig strictness go vet, -race, static types race detector is the one you'll miss most from not having

A few more words you'll trip on in READMEs:

What to unlearn

A short list of TypeScript instincts that hurt in Go:

  1. Feeling guilty about blocking. In Node, a sync fs.readFileSync in a request handler is a bug. In a goroutine, a blocking read is normal. The runtime parks you and runs someone else.
  2. Marking functions async up front. In Go you write a synchronous-looking function and the caller decides to go it. Library APIs take a context.Context and block; they don't return a Future.
  3. Sharing a mutable object "because it's fine on one thread". It isn't. If two goroutines touch it, use a channel or a mutex. Run tests with -race.
  4. Ignoring errors until a catch. Go's error is a value. The idiom is if err != nil { return ..., err }. Panic is for "this program is broken", not for "the file wasn't found".
  5. Sleeping to "wait for things to finish". That's how you get flaky tests. Use a WaitGroup, a channel, or a context.

Further reading