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 waitsA few things that surprise people coming from TypeScript:
- Any function can be
go'd. There's noasynckeyword on the definition. Concurrency is decided at the call site, not when you write the function. - Blocking is fine.
time.Sleep, a slow HTTP call, a mutex lock. The scheduler parks that goroutine and runs others. You don't need to sprinkleawaitto stay polite. - They're cheap. A goroutine starts with a tiny stack (a few KB) that grows as needed. Spawning thousands is normal. Spawning thousands of OS threads is not.
- There's no return value.
go f()discards whateverfreturns. If you need a result, send it on a channel or write it somewhere the caller can see (carefully).
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 zeroThe 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.
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.
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:
WithTimeout≈AbortSignal.timeout(2000)shared by every fetch.go func...≈ launching a Promise per URL without awaiting yet.- Buffered
ch≈ collecting settlements into an array you'll read from. selectonctx.Done()vsch≈Promise.racebetween a timeout and the next result.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:
- CSP: Communicating Sequential Processes, the model channels come from.
- GMP: Goroutine / Machine (OS thread) / Processor (logical slot). The scheduler's three letters. Useful when you're debugging why something isn't parallel; ignore it on day one.
GOMAXPROCS: how many OS threads may run Go code at once. Defaults to the CPU count. The dial for parallelism, not concurrency.- buffered vs unbuffered: capacity of a channel. Unbuffered = synchronous handoff. Buffered = queue with a limit.
- fan-out / fan-in: start many goroutines (out), then merge their results into one channel (in). The
firstOKexample is fan-out with an early exit. - worker pool: fixed number of goroutines pulling tasks from a jobs channel. Use it when unbounded
gowould stampede a database or an API.
What to unlearn
A short list of TypeScript instincts that hurt in Go:
- Feeling guilty about blocking. In Node, a sync
fs.readFileSyncin a request handler is a bug. In a goroutine, a blocking read is normal. The runtime parks you and runs someone else. - Marking functions
asyncup front. In Go you write a synchronous-looking function and the caller decides togoit. Library APIs take acontext.Contextand block; they don't return a Future. - 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. - Ignoring errors until a catch. Go's
erroris a value. The idiom isif err != nil { return ..., err }. Panic is for "this program is broken", not for "the file wasn't found". - Sleeping to "wait for things to finish". That's how you get flaky tests. Use a WaitGroup, a channel, or a context.
Further reading
- Go Concurrency Patterns, the official pipelines / fan-in / fan-out post
- Share Memory By Communicating, the codelab behind the slogan
- Go Concurrency Patterns: Context, why
ctxis the first argument everywhere - The Go Memory Model, when a read is guaranteed to see a write
- Effective Go, the short concurrency section worth reading twice
- Rob Pike: Concurrency is not Parallelism, the talk that makes the hero diagram click