Why concurrent code gives different answers on different runs, why it sometimes stops forever — and the tools and habits that find and prevent both, in Go, JavaScript and the database.
In one sentenceA race condition is a bug where the result depends on who gets there first. A deadlock is a bug where everyone is waiting for someone else, so nothing ever happens again.
Whenever two things run concurrently — two goroutines, two async functions, two HTTP requests, two servers — their steps get mixed together in some order. Usually you can't control that order, and it changes from run to run. A Race conditionRace conditionA bug where the result depends on the timing or order in which concurrent tasks happen to run. Run the same code twice, get two different answers. is code that's only correct for some of those orders.
Too much milk: a race condition with no computers involved
you
fridge: no milk
walking to the shop…
buy milk
walking home
🥛 put away
roommate
fridge: no milk
walking to the shop…
buy milk
🥛🥛 two!
Each roommate's logic is perfectly reasonable on its own. The bug only exists in the overlap: both checked before either acted.
Programs do exactly this. Two requests both check "is this username taken?", both see "no", and both create the account. Two goroutines both read count = 5, both add 1, and both write 6. You'll see a dozen real versions of this bug on this page.
Three families of concurrency bugs
≠
Race condition
Wrong answer, sometimes. Usually right, so tests pass. Then one day, under load, two orders get the same ticket. §02–§06.
∞
Deadlock
Stuck forever. Each task holds something the other needs and waits for it. CPU drops to 0%, requests hang. §07–§08.
↻
Livelock & starvation
Busy, but going nowhere — or one task never gets its turn while the others are fine. §09.
All three come from the same root: several tasks sharing something (a variable, a lock, a database row, a file) without agreeing on the order they use it in. And all three are timing-dependent: they hide on your laptop and appear in production, under real load, at the worst moment.
Foundations§02
Why count++ isn't one step
Here's the smallest race there is. Ten goroutines each add 1 to a shared counter a thousand times. The answer should be 10,000:
counter.go
package main
import (
"fmt""sync"
)
funcmain() {
count :=0var wg sync.WaitGroupfor i :=0; i < 10; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
for j :=0; j < 1000; j++ {
count++ // ← ten goroutines, one variable, no protection
}
}()
}
wg.Wait()
fmt.Println(count) // 10000? On a multi-core machine: often less, and different every run
}
Run it a few times and you'll typically see numbers like 6,412, then 8,907, then 10,000 (exact values depend on your machine). Increments are going missing. (wg is a sync.WaitGroupsync.WaitGroupA counter for “how many goroutines am I waiting on?”: Add(n) before starting them, Done() when each finishes, Wait() blocks until it reaches zero.: it makes main wait until all ten goroutines call Done().)
count++ is really three steps
You write one line, but it isn't indivisible: it's a Read-modify-writeRead-modify-writeRead a value, compute a new one from it, write it back — like count++. Unsafe if someone else can write in between your read and your write., and another goroutine can act between its parts. Logically, it does:
Readcount from memory into a private scratch slot (call it tmp).
Add 1 to tmp.
Writetmp back to count.
Each goroutine has its own tmp. If two goroutines both read before either writes, they both compute the same new value, and one increment is lost. Whether that happens depends on the InterleavingInterleaving (schedule)One possible order in which the steps of two or more concurrent tasks get mixed together. Each run of the program may pick a different one. — the order in which the two goroutines' steps get mixed. Go's scheduler (the part of the Go RuntimeRuntimeCode that ships inside every program of a language to support it while it runs. Go's runtime schedules goroutines and manages memory; Node's includes the event loop. that decides which goroutine runs when, and on which core) decides that, not you. Below, you decide:
▶You are the scheduler
Two goroutines each run count++, which the CPU really does in three steps. Click which goroutine runs its next step, in any order you like.
G1 · count++tmp = —
▶ tmp := count
tmp = tmp + 1
count = tmp
G2 · count++tmp = —
▶ tmp := count
tmp = tmp + 1
count = tmp
shared memory
count
0
order so far:nothing yet
Pick a goroutine to start. Try G1, G1, G1, G2, G2, G2 first — then try G1, G2, G1, G2, G1, G2.
Two things to notice:
Most orders are wrong. Click "show all possible orders": only 2 of the 20 give 2. On a machine with one CPU coreCPU coreOne independent “brain” inside the CPU. Each core runs exactly one thread at any instant, so a 4-core machine runs at most 4 threads truly at the same time., the bug is still fairly rare, because a goroutine usually runs a long stretch before being switched out. But on a multi-core machine (nearly every laptop and server today), G1 and G2 run on different cores at the very same instant, so their steps genuinely overlap — no switching needed. That's why the counter program above loses thousands of increments.
The lock removes the bad orders. With the mutex, G2 can't start reading until G1 has written and unlocked. The three steps become a Critical sectionCritical sectionThe few lines of code that run while holding a lock. Only one goroutine can be inside at a time, so keep them short.: a stretch only one goroutine can be in at a time.
Two fixes in Go
var mu sync.Mutex// one lock guarding countgofunc() {
defer wg.Done()
for j :=0; j < 1000; j++ {
mu.Lock() // wait here until no one else holds the lock
count++ // read, add, write — nobody can squeeze in
mu.Unlock() // let the next goroutine in
}
}()
And in JavaScript?
The same loop in JS is always correct: one thread runs your code, and it never stops in the middle of synchronous code. But add an await and the three steps can be pulled apart again:
let count = 0;
awaitPromise.all(
Array.from({ length: 10 }, async () => {
for (let j = 0; j < 1000; j++) count++; // nothing can interrupt this loop
})
);
console.log(count); // 10000, always
count += await getOne() is short for count = count + await getOne(), and JavaScript evaluates left to right: it reads count (0), then pauses at the await. All ten calls read 0 before any of them resumes, so each writes 0 + 1. Fix: const n = await getOne(); count += n; — now the read and the write sit together after the await, with no pause between them.
Practitioner§03
Data races vs race conditions
People say "race" for two different bugs. Telling them apart matters, because the tools that catch one are blind to the other.
D
Data race
A memory-level bug. Two goroutines touch the same variable, at least one writes, and nothing orders the two accesses (no lock, no channel). Needs memory shared between threads or goroutines.
R
Race condition
A logic bug. The program's result depends on the order things happened in. Can happen anywhere: goroutines, async JS, two servers, two browser tabs.
⚖
The overlap
count++ with no lock is both. But each can exist without the other — and that's the interesting part.
Two different bugs that share a name
wrong result possible (race condition)
order doesn't change the intended result
data race
Both
Go: many goroutines run count++ with no lock. Memory is touched unsafely and the total comes out wrong.
go test -race finds it ✓
Data race only
Go: two goroutines both set ready = true, with no lock. Whichever writes last, the value is true — but it's still a data race, and in Go a data race means anything may happen. Fix it anyway.
go test -race finds it ✓
no data race
Race condition only
Every access is locked — but “is the seat free?” and “book it” are two separate locked steps. Or JS: a check, then await, then act.
No tool finds it ✗ — only careful design and tests
Neither
The check and the act happen inside one lock, one atomic operation, or one SQL statement.
Correct ✓
Rows: is shared memory touched without synchronization? Columns: can a different order give a wrong result? The bottom-left box is the one tools can't see; the top-right one is still a bug in Go.
A data race, defined precisely
A Data raceData raceTwo threads or goroutines access the same memory, at least one of them writes, and nothing orders the two accesses (no lock, channel or atomic between them). The result is unpredictable. needs all three of:
Two goroutines (or threadsThreadOne worker that runs your code line by line. A program can have several threads, and the operating system decides which one runs on which CPU core.) access the same memory location,
at least one of the accesses is a write, and
no synchronization orders them — no mutex both of them use, no channel send/receive between them, no WaitGroup.
"Orders them" has a precise name: Happens-beforeHappens-beforeA guarantee that everything one goroutine did before an event is visible to another goroutine after it. Channel operations, a mutex Unlock followed by Lock, and atomic operations all create these guarantees.. If goroutine A unlocks a mutex and goroutine B locks it afterwards, everything A did before the unlock happens before everything B does after the lock. With no such link, the two accesses are "concurrent" — even if, on the clock, they were a second apart, and even on a single core. A data race is defined by that missing order, not by actual timing.
Why "it's just a bool" is still a bug
This looks harmless — one goroutine sets a flag, another waits for it:
var done boolgofunc() {
doWork()
done = true// write…
}()
for !done { // …read, with nothing ordering the two
}
fmt.Println("finished")
It's a data race, and it can genuinely hang. With no synchronization, the compiler is allowed to assume no other goroutine changes done — so it may legally turn your loop into this:
✕ What you wrote
for !done { // you meant: re-read done every time round
}
✓ What the compiler may legally run
if !done {
for { // read done ONCE… then spin forever
}
}
CPUs also keep values in per-core caches, so even without that rewrite, nothing guarantees when the write becomes visible to the other core. Synchronization is what forces "re-read it, and see the latest write":
var done atomic.Bool// sync/atomic, Go 1.19+gofunc() {
doWork()
done.Store(true)
}()
for !done.Load() { // re-reads every loop ✓ (but burns CPU while waiting)
}
A race condition with no data race
Now the opposite: every access is properly locked, the race detector is happy — and the code is still wrong.
typeSeatsstruct {
mu sync.Mutex
booked map[string]bool
}
func (s *Seats) IsFree(seat string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return !s.booked[seat]
}
func (s *Seats) Book(seat string) {
s.mu.Lock()
defer s.mu.Unlock()
s.booked[seat] = true
}
// In each request handler:if seats.IsFree("12A") { // check (lock, read, unlock)
seats.Book("12A") // act (lock, write, unlock) ← someone else may have booked in between
}
Each method is safe on its own. The bug is the gap between the two calls, when the lock isn't held. That's a Check-then-actCheck-then-actLook at some state (“is the seat free?”), then do something based on it (“book it”). Unsafe if the state can change between the check and the act. race: a logical race condition. No tool will flag it. The fix is to make the check and the act one critical section:
// TryBook books the seat only if it's free, and reports whether it did.func (s *Seats) TryBook(seat string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.booked[seat] {
returnfalse
}
s.booked[seat] = true// check and act under ONE lockreturntrue
}
JavaScript: no data races, plenty of race conditions
Ordinary JavaScript has no data races at all: one thread runs your code, so two lines can never touch a variable at the same instant. (The exception is SharedArrayBufferSharedArrayBuffer + AtomicsA block of raw bytes that several JS threads can see at the same time (real shared memory). Atomics gives safe operations on it, like an increment that can't be interrupted halfway. memory shared between worker threads — rare in everyday code.)
But JS has every kind of logical race, because each await is a point where other code runs. If you checked something before an await, it may be false after it. The withdraw race on the async/await page is the classic example; §04 shows the ones you'll meet at work.
Practitioner§04
Logical races in the wild
Logical race conditionsRace conditionA bug where the result depends on the timing or order in which concurrent tasks happen to run. Run the same code twice, get two different answers. all share one shape: look at something, then act on what you saw — with a gap in between where the world can change. Here are the ones you'll meet most. You already know read-modify-write from §02; the other three get a subsection each below, from inside one process to across the whole internet.
Pattern
The check
The gap
The act
Read-modify-writeRead-modify-writeRead a value, compute a new one from it, write it back — like count++. Unsafe if someone else can write in between your read and your write.
read count
another write lands
write count + 1
Check-then-actCheck-then-actLook at some state (“is the seat free?”), then do something based on it (“book it”). Unsafe if the state can change between the check and the act.
"is it in the cache?"
an await
"put it in the cache"
TOCTOUTOCTOU“Time of check to time of use”: a check-then-act race where the thing you checked — often a file — changes before you use it. (files)
"does the file exist?"
another process creates it
create / write the file
Double submit
"the user clicked buy"
the user clicks again
create an order
1. Check-then-act across await: the duplicate fetch
A cache that's supposed to fetch each user once:
const cache = newMap();
asyncfunctiongetUser(id) {
if (!cache.has(id)) { // check
cache.set(id, awaitfetchUser(id)); // act — but only AFTER an await
}
return cache.get(id);
}
When a page renders, three components call getUser(7) at almost the same moment. Each call checks the cache before any fetch has finished:
Check-then-act across await: three callers, three fetches
caller 1
has? no
await fetchUser(7)
set
caller 2
has? no
await fetchUser(7)
set
caller 3
has? no
await fetchUser(7)
set
All three checks run before the first fetch returns, so all three see an empty cache.
Three requests instead of one. Under real traffic, a popular key expiring can send thousands of identical requests to your database at once — a cache stampede. The fix is to make the check and the act synchronous by caching the promise, not the value:
const cache = newMap();
functiongetUser(id) {
let p = cache.get(id);
if (!p) {
p = fetchUser(id); // start the fetch, don't await it
cache.set(id, p); // check + act with no await between ✓
p.catch(() => cache.delete(id)); // don't keep failures forever
}
return p; // every caller awaits the same promise
}
singleflight only merges calls that are in flight at the same time; it doesn't store results. Put a normal cache in front of it for that.
2. TOCTOU: files that change under you
TOCTOUTOCTOU“Time of check to time of use”: a check-then-act race where the thing you checked — often a file — changes before you use it. is check-then-act where the thing you checked lives outside your program — usually a file. Here the "other side" isn't another goroutine: it's another process, or another copy of your server, which no lock in your program can stop.
TOCTOU: the file changed between the check and the use
process A
exists(x)? no
…a few µs…
write x ✗ clobbers B
process B
create x ✓
Process A's check was true when it ran. By the time A acts on it, it's false — and A overwrites B's file.
✕ Don't
import { existsSync, writeFileSync } from"node:fs";
if (!existsSync(path)) { // check…writeFileSync(path, data); // …use. Someone may have created it in between.
}
✓ Do
import { writeFileSync } from"node:fs";
try {
writeFileSync(path, data, { flag: "wx" }); // create, but FAIL if it exists
} catch (err) {
if (err.code !== "EEXIST") throw err;
// someone else got there first — handle it
}
Go: the same fix
import (
"errors""io/fs""os"
)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, fs.ErrExist) {
return errAlreadyExists // someone else created it first — stop here
}
if err != nil {
return err
}
defer f.Close()
// … write to f …
The pattern is "don't ask, just do it, and handle the failure." The wx flag (Node) and O_EXCL (Go) tell the Operating system (OS) / kernelOperating system (OS) / kernelThe core program (Linux, macOS, Windows) that manages hardware, memory, threads, files and network connections for every other program. The kernel is its innermost part. to check and create in one indivisible step. The OS owns the file system, so it's the only one that can do both at once.
3. The double-submitted form
A user clicks "Pay", nothing seems to happen, so they click again. Or their phone loses signal and the browser retries. The server receives two valid requests:
Double submit: one intent, two orders
browser
click
slow…
click!
waiting for answers
request 1
POST /orders
order #41
charge card
request 2
POST /orders
order #42 ✗
charge again ✗
The server sees two perfectly valid requests. Nothing tells it they are the same click — unless the client sends an idempotency key.
Disabling the button after the first click helps, but it can't stop a retry, a second tab, or a flaky network. The real fix is on the server: give each intent to buy a unique ID — an Idempotency keyIdempotency keyA unique ID the client sends with a request (e.g. an Idempotency-Key header) so the server can recognise a repeat and do the work only once. — and let the database refuse the second one.
// Create the key when the checkout form OPENS — not on each click.const idempotencyKey = crypto.randomUUID();
payButton.addEventListener("click", async () => {
payButton.disabled = true; // nice for the user, not enough on its ownawaitfetch("/orders", {
method: "POST",
headers: { "Idempotency-Key": idempotencyKey, "Content-Type": "application/json" },
body: JSON.stringify(order),
});
});
The UNIQUE constraint turns "is this a repeat?" + "create the order" into one step that the database guarantees. Notice where the charge sits: after the insert, and only for the request whose insert won. Retrying is now IdempotentIdempotentDoing it twice has the same effect as doing it once. “Set balance to 50” is idempotent; “add 50 to balance” is not — so only the first is safe to retry blindly.: doing it twice has the same effect as doing it once.
The common cure
Every fix on this page moves the check and the act into one indivisible step, owned by whoever owns the data:
Data lives in…
One-step tool
A Go variable
a mutex, or sync/atomic
A JS variable
synchronous code — no await between check and act
A file
O_EXCL / "wx" (let the OS check)
A database row
one SQL statement, a UNIQUE constraint, or a transaction that locks the row with SELECT … FOR UPDATE (§05) — a plain transaction is not enough
Practitioner§05
Two requests, one row: lost updates
Every web server handles requests concurrently: Go runs each request in its own GoroutineGoroutineA very lightweight task in Go, started with the go keyword. The Go runtime runs thousands of them on just a few OS threads., and Node interleaves them at every await. So when two requests change the same database row, you have count++ again (§02) — just with the database as the shared memory:
The trapA mutex in your server doesn't help. You run three copies of the server, and they share one database. The fix has to live in the database.
Step through all five versions of buy(). The first two are buggy — yes, including the one wrapped in a transaction. The last three are fixes.
▶Two requests, one row
Alice and Bob buy the same mug at the same moment. Step through each version of buy(): ① and ② are buggy, ③–⑤ are fixes.
Code
1asyncfunctionbuy(id) {
2const { rows } = await db.query(
3"SELECT stock FROM products WHERE id = $1", [id]);
4const stock = rows[0].stock;
5if (stock < 1) thrownewError("sold out");
6await db.query(
7"UPDATE products SET stock = $1 WHERE id = $2",
8 [stock - 1, id]);
9}
10
11buy(1); // Alice
12buy(1); // Bob
Alice's request
not started
Bob's request
not started
products row id = 1
stock = 5
The shop has 5 mugs. Alice and Bob click “buy” at the same moment, so the server runs buy(1) twice, concurrently. After both, stock should be 3.
1 / 5
Tab ① is a Lost updateLost updateTwo writers each read the same old value and write back their change; the second write silently erases the first.: both requests read 5, both compute 4 in their own code, and the second write erases the first. Note there's no data race anywhere — the database is perfectly safe to use concurrently. It's a pure logical race.
Tab ② is the trap most people fall into: wrapping the same code in BEGIN … COMMIT (a Database transactionDatabase transactionA group of database changes that happen all together or not at all (COMMIT keeps them, ROLLBACK undoes them). How much other users can see or interfere with the half-done state depends on the isolation level.) does not fix it at PostgreSQL's default settings. A transaction makes the second writer wait for the first, but it doesn't make it re-read — it still writes the number it computed from the old value.
Fix 1: let the database do the arithmetic
If the change can be written in SQL, do it in one statement (tab ③). The database locks the row while that statement runs (and until COMMIT, if it's inside a transaction), so the read and the write can't be separated:
const { rowCount } = await db.query(
`UPDATE products SET stock = stock - 1
WHERE id = $1 AND stock >= 1`, // check and act in ONE statement
[id]
);
if (rowCount === 0) thrownewError("sold out");
This is the best fix when it fits: no extra round trips, no retries, no locks held in your code. The WHERE … stock >= 1 is the check; the SET is the act; rowCount tells you which way it went.
Fix 2: optimistic locking with a version column
Sometimes the new value must be computed in your code — a document edit, a price with business rules. Then use Optimistic lockingOptimistic lockingDon't lock while reading. When writing, check that nobody changed the row since you read it (usually with a version number); if someone did, re-read and retry.: add a version column, and make every write say "only if the version is still the one I read":
UPDATE docs
SET body = $1, version = version + 1WHERE id = $2AND version = $3-- $3 = the version we read earlier
If someone else wrote in between, the version has moved on, no row matches, and you get rowCount 0. Then re-read and retry (tab ④), or — for a human editing a document — answer with HTTP 409 Conflict (the status code for "your change conflicts with the current state") and show "someone else changed this; here's the new version". Many ORMsORMObject-relational mapper: a library (Prisma, GORM, Hibernate…) that maps database rows to objects in your code and writes much of the SQL for you. build this in (it's often called a "version" or "concurrency token" field).
Fix 3: lock the row first (SELECT … FOR UPDATE)
The pessimistic option (tab ⑤): inside a transaction, read the row and lock it with a Row lockRow lock (SELECT … FOR UPDATE)Pessimistic locking (“lock first, then read and write”) in a database: reading a row with FOR UPDATE locks it until your transaction ends, so other transactions that want to lock or change it wait.. Other transactions that want to lock or change that row wait until you commitCOMMIT / ROLLBACKThe two ways to end a transaction: COMMIT keeps all its changes (and releases its locks); ROLLBACK undoes all of them.. Compared with tab ②, the second buyer now waits before reading, so it reads the fresh value.
const client = await db.connect(); // a transaction needs ONE connectiontry {
await client.query("BEGIN");
const { rows } = await client.query(
"SELECT stock FROM products WHERE id = $1 FOR UPDATE", [id]); // 🔒 until COMMITif (rows.length === 0) thrownewError("no such product");
if (rows[0].stock < 1) thrownewError("sold out");
await client.query(
"UPDATE products SET stock = $1 WHERE id = $2", [rows[0].stock - 1, id]);
await client.query("COMMIT"); // releases the row lock
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release(); // give the connection back to the pool
}
Which one?
Fix
Use when
Cost
One UPDATE … SET x = x - 1 WHERE …
The change fits in SQL
Cheapest. Use it whenever you can.
Version column (optimistic)
Logic lives in code; conflicts are rare; users edit things
A retry or a 409 when conflicts happen
SELECT … FOR UPDATE (pessimistic)
Logic lives in code; conflicts are common (a hot row)
Others wait; can deadlock (§10)
Practitioner§06
Go's race detector
Data racesData raceTwo threads or goroutines access the same memory, at least one of them writes, and nothing orders the two accesses (no lock, channel or atomic between them). The result is unpredictable. are hard to find by reading code and nearly impossible to reproduce on purpose. So Go ships a tool that finds them for you: the Race detectorRace detectorGo's built-in checker (go test -race). It watches every memory access while the program runs and reports two goroutines touching the same memory with nothing ordering them.. You turn it on with one flag:
go test -race ./... # run all tests with race detection ← the one to put in CIgo run -race . # run a program with race detectiongo build -race # build a race-detecting binary (e.g. for a test server)
What it does, in plain words
With -race, the compiler adds a tiny check around every memory read and write in your program. While the program runs, the detector keeps a logbook:
for each piece of memory, the last few goroutinesGoroutineA very lightweight task in Go, started with the go keyword. The Go runtime runs thousands of them on just a few OS threads. that touched it (and whether they read or wrote), and
for each goroutine, which synchronization events it has "seen": mutex unlocks it came after, channel messages it received, wg.Wait()s that returned.
On every access, it asks: is this access ordered after the previous one by some chain of locks, channels or WaitGroups? If not, and one of them is a write, that's a data race — the precise definition from §03 — and it prints a report.
Reading the report
Here's a tiny program with the count++ race from §02, using just two goroutines:
main.go
package main
import (
"fmt""sync"
)
funcmain() {
count :=0var wg sync.WaitGroupfor i :=0; i < 2; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
count++ // ← line 15: the race
}()
}
wg.Wait()
fmt.Println(count)
}
go run -race . prints something like the report below. Step through it:
▶Reading a race report, block by block
This is the real shape of what go run -race prints for the counter program above.
Code
1==================
2WARNING: DATA RACE
3Read at 0x00c000012128 by goroutine 8:
4 main.main.func1()
5 /app/main.go:15 +0x7c
6
7Previous write at 0x00c000012128 by goroutine 7:
8 main.main.func1()
9 /app/main.go:15 +0x8e
10
11Goroutine 8 (running) created at:
12 main.main()
13 /app/main.go:13 +0x7a
14
15Goroutine 7 (finished) created at:
16 main.main()
17 /app/main.go:13 +0x7a
18==================
192
20Found 1 data race(s)
21exit status 66
your code
—
goroutines involved
—
The detector prints one block like this per race it finds, framed by ==================. Seeing WARNING: DATA RACE means: two goroutines touched the same memory and nothing (no lock, no channel, no WaitGroup) ordered those two accesses. It is never a false alarm.
1 / 5
The habit that makes reports easy: find the two line numbers (here both are line 15), then ask "which lock or channel was supposed to order these two?" Usually the answer is "none — I forgot one".
What it costs
Every memory access now does extra bookkeeping. Go's documentation says to expect roughly 2–20× slower execution and 5–10× more memory (approximate; it depends heavily on the program). That's fine for tests and for a test server getting realistic traffic; too expensive for most production servers.
What it can't find
The detector is precise — when it reports a race, the race is real — but it only sees what actually happens during that run:
🙈
Code that didn't runIt watches executions, it doesn't analyze source. A race in a code path your tests never take stays hidden. Tests must actually run things concurrently.
🧠
Logical racesCheck-then-act with proper locks (§03), lost updates in the database (§05), every JS await race: there's no unsynchronized memory access, so there's nothing to report.
🍀
Orders that happened to be safeIf, in this run, the two accesses were ordered by some unrelated lock — say, both goroutines happened to call log.Printf, which uses a mutex inside — the detector sees them as ordered and stays quiet. Adding a log line can hide a race from it.
🔒
DeadlocksIt's a race detector, not a deadlock detector. A program that hangs forever under -race just hangs (see §11).
And JavaScript?
There's no race detector for JavaScript, because ordinary JS has no data races to detect (§03). Its races are all logical, so they're found the way logical races always are: by design (no await between check and act; one SQL statement) and by tests that force the bad order. For example, make a fake fetchUser that doesn't resolve until the test says so, start two getUser(7) calls, then resolve it — and assert it was called once.
cache.test.js
import { test } from"node:test";
import assert from"node:assert/strict";
test("concurrent getUser calls share one fetch", async () => {
let calls = 0, release;
const gate = newPromise((r) => (release = r)); // stays pending until release()const fetchUser = async (id) => { calls++; await gate; return { id }; };
// makeCachedGetUser: the §04 cache, written to take fetchUser as a parameter// so the test can pass in this fake.const getUser = makeCachedGetUser(fetchUser);
const both = Promise.all([getUser(7), getUser(7)]); // start both BEFORE anything resolvesrelease(); // now let the fetch finishawait both;
assert.equal(calls, 1); // fails on the buggy version: 2
});
The test controls the timing, so the bad InterleavingInterleaving (schedule)One possible order in which the steps of two or more concurrent tasks get mixed together. Each run of the program may pick a different one. happens on every run instead of once a month in production.
Foundations§07
Deadlock: everyone waits forever
In one sentenceA deadlock is a circle of waiting: A waits for something B holds, B waits for something A holds, and neither will let go first. Nothing crashes. Everything just stops.
The classic: two locks, opposite order
A bank transfer locks both accounts, so no one else changes either balance mid-transfer. Each account has its own mutexLock (mutex)A “do not disturb” sign for a piece of data: only the task holding the lock may touch it; others wait their turn.:
typeAccountstruct {
mu sync.Mutex
balance int
}
functransfer(from, to *Account, amount int) {
from.mu.Lock() // 1. lock the source…defer from.mu.Unlock()
to.mu.Lock() // 2. …then the destination ← can wait foreverdefer to.mu.Unlock()
from.balance -= amount
to.balance += amount
}
// At the same moment, two requests arrive:gotransfer(alice, bob, 10) // locks alice, then wants bobgotransfer(bob, alice, 5) // locks bob, then wants alice
Most of the time one transfer finishes before the other starts, and all is well. But if both run step 1 before either reaches step 2:
transfer #1 holds alice and waits for bob;
transfer #2 holds bob and waits for alice.
Neither can continue, so neither ever unlocks. Every later request touching alice or bob queues up behind them, forever.
Five philosophers, five forks
Computer scientists tell this story with the dining philosophers. Five philosophers sit at a round table with one fork between each pair. To eat, a philosopher needs both forks next to them. Each one follows a simple rule: pick up the left fork, then the right fork, eat, put both down.
Play with it. Then press "Everyone grabs at once":
▶The dining philosophers
Five philosophers, five forks. Each needs the two forks next to them to eat. Click a philosopher to give them a turn, or let it run.
Rule: pick up your left fork first, then the other. Hold the first while waiting for the second. Eat, then put both down.
Try “everyone grabs at once”. Then switch on the fix and try it again.
When everyone picks up their left fork at the same moment, every fork is taken and everyone is waiting for their right fork — which their neighbour is holding. A closed circle of waiting. The red arrows show it.
The fix: everyone agrees on one order
Switch on "fix: lower number first". Now each philosopher picks up their lower-numbered fork first. For four of them that's still the left fork. But P4 sits between forks 4 and 0, so P4 now reaches for fork 0 first — the same fork P0 wants first. They compete for it, one of them gets it, and the circle can't close: someone always ends up with two forks.
That's Lock orderingLock orderingA rule that all code takes locks in the same global order (e.g. lowest account ID first). With one order, a waiting cycle can't form., and it's the most common deadlock fix in real code. For the bank:
functransfer(from, to *Account, amount int) {
if from == to {
return// locking the same mutex twice would deadlock on its own
}
first, second := from, to
if to.id < from.id { // always lock the account with the smaller ID first
first, second = to, from
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
from.balance -= amount
to.balance += amount
}
Now both transfers between alice (id 1) and bob (id 2) lock alice first. Whichever gets alice first proceeds; the other simply waits for it to finish. Waiting, but not circular waiting.
Practitioner§08
The four conditions — break any one
In 1971, Edward Coffman and colleagues showed that a DeadlockDeadlockTwo or more tasks each wait for something another one holds, so none can ever continue — e.g. transaction A waits for B's row lock while B waits for A's. Go crashes with “all goroutines are asleep” when every goroutine is stuck; databases detect it and abort one transaction. can only happen when four conditions hold at the same time. They're now called the Coffman conditionsCoffman conditionsThe four conditions that must all hold for a deadlock: mutual exclusion, hold and wait, no preemption, circular wait. Break any one and deadlock becomes impossible.. This is more useful than it sounds: it means you never need a clever fix — you just need to make one of the four impossible.
The four conditions — a deadlock needs all of them at once
1Mutual exclusion
A resource can be held by only one task at a time. (That's the whole point of a lock.)
2Hold and wait
A task keeps what it already holds while it waits for more.
3No preemption
Nobody can take a resource away from its holder. Only the holder can let go.
4Circular wait
A closed loop: G1 waits for G2, G2 waits for G1 (or a longer ring).
Circles are goroutines, squares are locks. Solid arrow: the lock is held by that goroutine. Dashed arrow: the goroutine is waiting for that lock.
See all four at once
Below, three goroutines each hold one lock. You choose what each one is waiting for. The picture is a Wait-for graphWait-for graphA drawing of who waits for whom: an arrow from each waiting task to whatever it's waiting for. A deadlock is a loop (a cycle) in this graph.: solid grey arrows mean "this lock is held by that goroutine", dashed arrows mean "this goroutine is waiting for that lock".
▶Find the cycle
Each goroutine already holds one lock. Choose what each one is waiting for. A deadlock is a closed loop of arrows.
G1 holds A, wants
G2 holds B, wants
G3 holds C, wants
● holdsMutual exclusion— each lock has exactly one holder
● holdsHold and wait— someone holds a lock and waits for another
● holdsNo preemption— sync.Mutex can't be taken away
● holdsCircular wait— there's a loop
💀 Deadlock. All four conditions hold. The loop: G1 → B → G2 → C → G3 → A → G1. Every goroutine in it waits for another one in it.
Try these:
The full ring (the starting setup): G1 → B → G2 → C → G3 → A → G1. Deadlock.
Set G3 to "nothing". The ring opens. G3 finishes, releases C, which lets G2 finish, which lets G1 finish.
A two-goroutine loop: G1 wants B, G2 wants A. That's the bank transfer from §07.
Switch on "enforce A → B → C". Now try to build any loop. You can't: every arrow points forward in the order, so none can lead back.
Notice that mutual exclusion and no preemption hold in every case, and hold-and-wait holds whenever anyone is waiting. The deadlock appears and disappears with circular wait alone — so breaking that one condition is enough.
Breaking each condition
Condition
Break it by…
In practice
1. Mutual exclusionMutual exclusionThe guarantee that only one task at a time runs a given piece of code. “Mutex” is short for it.
Not sharing the thing at all
Give data a single owner goroutine and send it messages (channels); use immutable data; use an atomicAtomic operationAn operation that happens as one indivisible step: nobody can ever see it half-done or squeeze in between its parts. Go's sync/atomic package offers atomic counters. for a single counter.
2. Hold and wait
Never waiting while holding
Use one coarser lock instead of several (e.g. one lock for "all accounts" instead of one per account), so there's never a second lock to wait for; release your lock before calling something slow or unknown.
3. No preemption
Letting a held lock be taken back
You can't snatch a lock from another goroutine, but a goroutine can give up its own: try-lock, or a lock wait with a timeout — if you can't get the next lock, release what you hold and retry later.
4. Circular wait
A global order
Lock orderingLock orderingA rule that all code takes locks in the same global order (e.g. lowest account ID first). With one order, a waiting cycle can't form.: always take locks in the same order (by ID, by name, by level). The most common fix.
What "giving up" looks like in code
Both snippets below break condition 3, no preemption: when a goroutine can't get what it needs, it lets go of what it already holds instead of waiting forever.
Go's sync.Mutex has no timeout, but it has TryLock (Go 1.18+): take the lock only if it's free right now. (The Go docs warn that correct uses of TryLock are rare — reach for lock ordering first.) For a lock with a deadline, use a weighted SemaphoreSemaphoreA counter of free slots. acquire() takes a slot (waiting if there are none left); release() gives it back. A semaphore with 10 slots lets at most 10 tasks through at once., which accepts a context.Contextcontext.ContextA value passed as the first argument through Go calls that carries a deadline and a “stop now” signal. ctx.Done() is a channel you can receive from once it's time to stop.:
import"golang.org/x/sync/semaphore"
lock := semaphore.NewWeighted(1) // a lock with room for 1 holder
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defercancel()
if err := lock.Acquire(ctx, 1); err != nil {
return fmt.Errorf("gave up waiting for the lock: %w", err) // no deadlock: we stopped waiting
}
defer lock.Release(1)
// … critical section …
Practitioner§09
Livelock and starvation
A DeadlockDeadlockTwo or more tasks each wait for something another one holds, so none can ever continue — e.g. transaction A waits for B's row lock while B waits for A's. Go crashes with “all goroutines are asleep” when every goroutine is stuck; databases detect it and abort one transaction. is the loud failure: everything stops. Its two cousins are quieter, because the program is visibly doing something.
∞
Deadlock
Everyone waits. CPU ~0%. No progress for anyone.
↻
Livelock
Everyone keeps reacting to everyone else. CPU busy. No progress for anyone.
⌛
Starvation
The system makes progress — but one task never gets a turn.
Livelock: too polite to proceed
A LivelockLivelockTasks keep running and reacting to each other but never make progress — like two people in a corridor who keep stepping aside in the same direction. often comes from a deadlock "fix". Take the two-lock transfer from §07 and make it polite: if the second lock is busy, release the first and try again.
for {
a.mu.Lock()
if b.mu.TryLock() { // got both → donebreak
}
a.mu.Unlock() // polite: give back what we hold…
time.Sleep(10 * time.Millisecond) // …wait, and try again
}
// (the other goroutine runs the same loop with a and b swapped)
There's no deadlock any more — nobody waits forever while holding a lock. But if two goroutines run this loop with the locks in opposite order, they can stay in lockstep: both lock, both fail, both release, both sleep exactly 10 ms, both lock again…
Livelock vs the same code with jitter
fixed back-off (10 ms)
G1: A then B
lock
try ✗
free
sleep
lock
try ✗
free
sleep
lock
try ✗
free
sleep
G2: B then A
lock
try ✗
free
sleep
lock
try ✗
free
sleep
lock
try ✗
free
sleep
random back-off (jitter)
G1: A then B
lock
try ✗
free
lock
got B ✓
work, unlock
G2: B then A
lock
try ✗
free
sleep (longer)
waits for B
lock
got A ✓
work
Top: both goroutines back off for exactly the same time, so they collide again and again — busy forever, zero progress. Bottom: each sleeps a random amount, they drift apart, and one gets both locks.
The fix is randomness. If each one sleeps a random amount — JitterJitterA small random amount added to a delay or timer — a retry wait, a reconnect, a connection's maximum age — so that many clients don't all act at the same instant and stampede a server. — they drift apart, and one gets through:
time.Sleep(time.Duration(rand.IntN(20)) * time.Millisecond) // math/rand/v2 (Go 1.22+): 0–19 ms
The same idea protects retries everywhere: database transactions retried after a deadlock (§10), clients retrying a failed server. Without jitter, everyone retries at the same instant and collides again (a Thundering herdThundering herdMany clients hitting a server at the same moment — e.g. all retrying together right after an outage — which knocks it over again.). Better still, use lock ordering (§08) and you never need the retry loop at all.
Starvation: never your turn
StarvationStarvationA task that is ready to run never gets its turn, because others keep getting the resource first. The system as a whole makes progress; that one task doesn't. means one task waits indefinitely while others keep going. Nothing is "stuck" in the deadlock sense — the lock is constantly being used, just never by you. Three places it shows up:
1. Readers starving a writer. A read-write lockRWMutex (read/write lock)A lock with two modes: many readers may hold it at the same time, but a writer needs it completely alone. lets many readers in at once, but a writer needs everyone out. If readers keep arriving so there's never a moment with zero readers, a naive implementation makes the writer wait forever:
Starvation: readers keep the lock busy, the writer never gets in
reader 1
read
read
read
reader 2
read
read
read
reader 3
read
read
read
writer
waiting for zero readers… still waiting… still waiting…
No single reader holds the lock for long, but there is never a moment with zero readers. A naive read-write lock lets new readers join, so the writer waits forever. (Go's sync.RWMutex prevents this: once a writer is waiting, new readers queue behind it.)
Go's sync.RWMutex avoids this: once a writer calls Lock(), newRLock() calls wait behind it. (Pitfall #6 in §12 shows the surprising deadlock this rule can cause.)
2. A greedy goroutine. If goroutine A unlocks and immediately locks again in a loop, it often wins the race against waiting goroutine B, because A is already running on a CPU while B has to be woken up first. Since Go 1.9, sync.Mutex has a starvation mode: once a waiter has waited more than 1 ms, the mutex is handed directly to the longest waiter, first-come first-served, until the queue drains.
3. Hogging the event loop (JS). In Node, one request doing 2 seconds of synchronous work (a huge JSON.parse, a slow regex) starves every other request for those 2 seconds — they're all waiting for the one thread. A promise chain that keeps scheduling more promise callbacks starves timers and I/O the same way (microtask starvation).
Telling them apart
Deadlock
Livelock
Starvation
CPU
Near 0%
Busy
Busy
Progress
None
None
Others yes, one task no
Typical sign
Requests hang; goroutine dump shows everyone waiting on locks
Retry/conflict logs flood; nothing completes
Some requests time out while most succeed
Typical fix
Lock ordering
Jitter, or remove the retry loop with lock ordering
Fair queueing; shorter critical sections; don't block the event loop
Staff-level§10
Deadlocks in the database
Databases lock rows too. Every UPDATE locks the rows it changes until the Database transactionDatabase transactionA group of database changes that happen all together or not at all (COMMIT keeps them, ROLLBACK undoes them). How much other users can see or interfere with the half-done state depends on the isolation level. ends, and SELECT … FOR UPDATE (§05) locks the rows it reads. So the bank-transfer DeadlockDeadlockTwo or more tasks each wait for something another one holds, so none can ever continue — e.g. transaction A waits for B's row lock while B waits for A's. Go crashes with “all goroutines are asleep” when every goroutine is stuck; databases detect it and abort one transaction. from §07 happens in SQL exactly the same way:
-- transaction 1: move 10 from account 1 to account 2BEGIN;
UPDATE accounts SET balance = balance - 10WHERE id = 1; -- 🔒 row 1UPDATE accounts SET balance = balance + 10WHERE id = 2; -- wants row 2COMMIT;
-- transaction 2, at the same time: move 5 from account 2 to account 1BEGIN;
UPDATE accounts SET balance = balance - 5WHERE id = 2; -- 🔒 row 2UPDATE accounts SET balance = balance + 5WHERE id = 1; -- wants row 1COMMIT;
Two transfers, opposite order — inside PostgreSQL
tx 1: 1→2
BEGIN
UPDATE row 1 🔒
UPDATE row 2: waits for tx 2
ERROR: deadlock
tx 2: 2→1
BEGIN
UPDATE row 2 🔒
UPDATE row 1: waits for tx 1
gets row 1 ✓
Each transaction holds one row lock and waits for the other's. After deadlock_timeout (1 s by default) Postgres checks its wait-for graph, finds the cycle and kills one transaction so the other can finish.
The database breaks the tie for you
Unlike a Go program, a database does detect deadlocks among its own transactions, because it knows exactly who holds and who waits for every row lock. It builds the Wait-for graphWait-for graphA drawing of who waits for whom: an arrow from each waiting task to whatever it's waiting for. A deadlock is a loop (a cycle) in this graph. from §08, finds the cycle, and kills one transaction — the victimDeadlock victimThe transaction a database chooses to abort (roll back) to break a deadlock cycle, so the others can finish. Your code should retry it. — so the others can go on:
Database
When it checks
What the victim gets
PostgreSQL
After a lock wait lasts deadlock_timeout (default 1 s)
ERROR: deadlock detected, SQLSTATESQLSTATEA standard five-character error code that databases return alongside the message, e.g. 40P01 = deadlock detected, 23505 = unique violation. Check the code, not the message text.40P01
MySQL (InnoDB)
Immediately, on every lock wait (by default)
ERROR 1213: Deadlock found when trying to get lock; try restarting transaction, SQLSTATE 40001
The victim's whole transaction is rolled back. Your code has to retry it — the message even says so. That makes deadlocks in the database survivable, but each one still costs a wait (up to a second in Postgres) and a retry.
Retry the whole transaction
These helpers are for PostgreSQL: the Go one uses the pgx driver (github.com/jackc/pgx/v5, whose errors are *pgconn.PgError; with the older lib/pq driver the type is *pq.Error), the JS one uses node-postgres. With MySQL's mysql2 package, check err.code === "ER_LOCK_DEADLOCK" (its errno is 1213) instead.
import (
"context""errors""math/rand/v2""time""github.com/jackc/pgx/v5/pgconn"
)
funcwithRetry(ctx context.Context, fn func() error) error {
for attempt :=1; ; attempt++ {
err :=fn() // fn runs the WHOLE transaction: BEGIN … COMMITvar pgErr *pgconn.PgError
retryable := errors.As(err, &pgErr) &&
(pgErr.Code == "40P01" || pgErr.Code == "40001") // deadlock / serialization failureif !retryable || attempt == 3 {
return err // success (nil), a real error, or out of attempts
}
// back off with jitter (§09) so the two victims don't collide again
wait := time.Duration(attempt*50+rand.IntN(50)) * time.Millisecondselect {
case<-time.After(wait):
case<-ctx.Done(): // the caller gave up (e.g. the HTTP request was cancelled)return ctx.Err()
}
}
}
Retry the entire transaction, never just the failed statement: the rollback undid everything before it too. And keep side effects (emails, HTTP calls to other services) outside the retried function, or a retry will send them twice.
Prevent it: the same rules as in Go
Lock rows in a consistent order. Sort the IDs in your code before updating, or lock them up front in order:
SELECT id FROM accounts WHERE id IN ($1, $2) ORDERBY id FORUPDATE;
Now every transfer locks the lower ID first — Lock orderingLock orderingA rule that all code takes locks in the same global order (e.g. lowest account ID first). With one order, a waiting cycle can't form., in SQL.
Keep transactions short. Every lock is held until COMMIT. Never make a network call, wait for a user, or do slow work inside a transaction.
Use one statement when you can.UPDATE … SET stock = stock - 1 (§05) holds its lock for microseconds.
Set a safety net. Postgres lock_timeout (e.g. SET lock_timeout = '2s') makes a statement fail instead of waiting behind a stuck lock forever. The failed transaction rolls back and releases its locks — the database version of breaking "no preemption" (§08).
Staff-level§11
Debugging a hang
A service has stopped answering. Before reaching for any tool, look at its CPU usage (top, or your dashboard). It splits the problem in two:
CPU
Probably
Next step
~0%
A DeadlockDeadlockTwo or more tasks each wait for something another one holds, so none can ever continue — e.g. transaction A waits for B's row lock while B waits for A's. Go crashes with “all goroutines are asleep” when every goroutine is stuck; databases detect it and abort one transaction., or everyone waiting on something that will never answer
Look at what every goroutine / request is waiting on
~100% of a core
An infinite loop, a livelock (§09), or a blocked JS event loop
Look at what's running: a CPU profile or a debugger pause
When Go tells you — and when it can't
The Go RuntimeRuntimeCode that ships inside every program of a language to support it while it runs. Go's runtime schedules goroutines and manages memory; Node's includes the event loop. has a built-in check. If everyGoroutineGoroutineA very lightweight task in Go, started with the go keyword. The Go runtime runs thousands of them on just a few OS threads. is blocked and nothing (a timer, the network, a system call) could ever wake one up, it stops the program:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
sync.(*Mutex).Lock(...)
main.main()
/app/main.go:9 +0x45
That's what you saw in §07 and in the channels pitfalls. But notice the word every. The check can't fire when even one goroutine might still wake up — and in real programs, one almost always can:
An HTTP server's accept loop is waiting on the network. A new connection could arrive.
A time.Ticker for metrics will fire again.
A goroutine is blocked in a system callSystem call (syscall)A request from your program to the operating system — “read this file”, “open a connection”. While a blocking syscall runs, the calling thread waits inside the OS., which the runtime can't see into.
So in a server, two deadlocked goroutines just sit there, silently, while everything else keeps running. Go's check is a nice safety net for small programs and tests; in production you need to look.
Step 1: get a goroutine dump
A Goroutine dumpGoroutine dumpA text listing of every goroutine in a running Go program: its stack trace and what it is waiting on (a lock, a channel, the network…). lists every goroutine with its stack and what it's waiting for. Two ways to get one:
kill -QUIT <pid> # or press Ctrl+\ in the terminal running it# The runtime prints every goroutine's stack to stderr (the error output), then exits with code 2.
SIGQUIT is the right tool when the process is already broken and you'll restart it anyway — say, a hung test (go test also dumps all goroutines when a test exceeds its -timeout, default 10 minutes). pprof is the right tool for production: you can take several dumps a minute apart without killing anything.
Step 2: read it
▶Reading a goroutine dump
A hung transfer service. Find the two goroutines that are waiting on each other.
Code
1goroutine 1 [IO wait, 12 minutes]:
2internal/poll.runtime_pollWait(…)
3…
4net/http.(*Server).ListenAndServe(…)
5main.main()
6 /app/main.go:31 +0x1d5
7
8goroutine 51 [sync.Mutex.Lock, 4 minutes]:
9sync.(*Mutex).Lock(…)
10 /usr/local/go/src/sync/mutex.go:46
11main.transfer(0xc000110000, 0xc000110018, 10)
12 /app/bank.go:21 +0x9e
13main.handleTransfer(…)
14 /app/main.go:18 +0x14c
15created by net/http.(*Server).Serve in goroutine 1
16
17goroutine 52 [sync.Mutex.Lock, 4 minutes]:
18sync.(*Mutex).Lock(…)
19 /usr/local/go/src/sync/mutex.go:46
20main.transfer(0xc000110018, 0xc000110000, 5)
21 /app/bank.go:21 +0x9e
22main.handleTransfer(…)
23 /app/main.go:18 +0x14c
24created by net/http.(*Server).Serve in goroutine 1
25
26… 214 more goroutines [sync.Mutex.Lock] in main.transfer …
bank.go
—
goroutines
—
A server stopped answering /transfer requests, but CPU is near 0%. You fetched /debug/pprof/goroutine?debug=2. This is a shortened version of what came back. Every block is one goroutine.
1 / 5
What to look for in any dump:
The bracket on each header line: [sync.Mutex.Lock] (before Go 1.20 it said [semacquire]), [chan send], [chan receive], [select] mean "waiting on another goroutine". [IO wait] usually means "waiting for the network" — normal for idle connections.
Durations.4 minutes on a lock wait is never normal. Take two dumps a minute apart: goroutines that haven't moved are your suspects.
Pile-ups. Hundreds of goroutines with the same stack (debug=1 counts them for you) point at the one place everything is queuing.
Extra frames are normal. The example above is trimmed. Real dumps show a few runtime frames above Lock (e.g. sync.runtime_SemacquireMutex, (*Mutex).lockSlow); skip down to the first frame in your code.
Pairs. For a deadlock, find two goroutines each blocked in a Lock — then work out, from their arguments or code, which lock each one already holds.
In Node.js
A hung Node process has the same two shapes, but the tools differ:
CPU at 100%: the event loop is blocked. One piece of synchronous code is running and won't stop, so nothing else — no request, no timer — gets a turn. Attach a debugger and press pause to see where:
node --inspect server.js # start with the debugger enabledkill -USR1 <pid> # …or switch it on in an already-running process (Linux/macOS)# Open chrome://inspect in Chrome → "inspect" → press ⏸ pause: you land on the running line.
CPU at 0%: a promise that never settles. Some await is waiting for a promise nobody will ever resolve — a lock never released (§07), a request with no timeout, a callback that's never called. There's no stack to pause on, because nothing is running. Useful tools:
node --report-on-signal server.js, then kill -USR2 <pid>: Node writes a JSON diagnostic report with the JavaScript stack and every open handle (sockets, timers, files).
process.getActiveResourcesInfo() (Node ≥ 17.3) returns what's keeping the process alive, e.g. ['TCPServerWrap', 'Timeout'].
If nothing is pending at all, Node simply exits — the awaiting code never runs. With a top-level await in an ES module, it exits with code 13 and Warning: Detected unsettled top-level await. That exit is Node's version of "all goroutines are asleep".
Practitioner§12
Pitfalls gallery
#1
“Fixing” a race with a sleep
Adding time.Sleep(100 * time.Millisecond) (or setTimeout(…, 100)) so that "the other one finishes first" doesn't fix a race. It changes the odds. On a busy server, a slow disk or a garbage-collection pause, the other one won't finish first, and the bug is back — now rarer and harder to find.
✕ Don't
goloadConfig()
time.Sleep(100 * time.Millisecond) // hope it's done by nowuse(config)
✓ Do
done :=make(chanstruct{})
gofunc() { loadConfig(); close(done) }()
<-done // wait for the actual event, not for a guessuse(config)
#2
An early return that skips the unlock
If any path out of the function forgets to unlock a LockLock (mutex)A “do not disturb” sign for a piece of data: only the task holding the lock may touch it; others wait their turn., the lock stays held forever and the next caller deadlocks. Pair every Lock with a defer Unlock on the very next line (Go), or try … finally (JS).
✕ Don't
mu.Lock()
v, ok := cache[k]
if !ok {
returnnil, errNotFound // ← returns while still holding mu
}
mu.Unlock()
return v, nil
✓ Do
mu.Lock()
defer mu.Unlock() // runs on every return path, and on panic
v, ok := cache[k]
if !ok {
returnnil, errNotFound
}
return v, nil
#3
Locking a mutex you already hold
Go's sync.Mutex is not re-entrantReentrant (recursive) lockA lock the same thread may take again while it already holds it. Go's mutexes are NOT reentrant: locking twice from the same goroutine waits forever.: if a goroutine that holds it calls Lock() again, it waits for itself — forever. This usually happens when one locked method calls another:
func (s *Store) Set(k, v string) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[k] = v
s.Log("set " + k) // Log also calls s.mu.Lock() → self-deadlock
}
The common convention: public methods lock; private helpers named …Locked (e.g. logLocked) assume the caller already holds the lock and never lock themselves.
#4
Copying a mutex
A sync.Mutex must not be copied after first use. Quick refresher: a method declared on Counter (a value receiver) receives a copy of the struct; a method declared on *Counter (a pointer receiver) works on the original. So a value-receiver method copies the mutex too — it locks a lock no one else uses (and changes a copy of the data).
✕ Don't
typeCounterstruct {
mu sync.Mutex
n int
}
func (c Counter) Inc() { // value receiver: c is a COPY
c.mu.Lock()
c.n++ // increments the copy; the original never changes
c.mu.Unlock()
}
✓ Do
func (c *Counter) Inc() { // pointer receiver: the real Counter
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
go vet catches this (its copylocks check prints "passes lock by value"). go test runs only a small subset of vet checks automatically, and copylocks isn't one of them — so run go vet ./... explicitly in CI.
#5
Writing a map from several goroutines
Go maps aren't safe for concurrent writes, and the runtime checks: instead of silently corrupting the map, it crashes the whole program with fatal error: concurrent map writes (or concurrent map read and map write). This is a fatal error, not a panic — recover can't catch it. Protect the map with a mutex, or use sync.Map for the special case of keys that are written once and read many times.
#6
Taking a read lock twice (RWMutex)
It seems safe for a reader to RLock again — readers share. But Go's RWMutexRWMutex (read/write lock)A lock with two modes: many readers may hold it at the same time, but a writer needs it completely alone. makes new readers wait once a writer is waiting (that's what prevents writer starvation, §09). So this sequence deadlocks:
Goroutine A: RLock() ✓
Goroutine B: Lock() — waits for A to finish reading
Goroutine A: RLock() again — waits behind B. A waits for B, B waits for A.
It only happens when a writer arrives at exactly the wrong moment, so it can pass every test. The sync docs say it plainly: recursive read locking is not allowed.
#7
Doing slow work while holding a lock
Everything inside a Critical sectionCritical sectionThe few lines of code that run while holding a lock. Only one goroutine can be inside at a time, so keep them short. runs one goroutine at a time. Put a network call in there and every other goroutine waits for the network too. Worse, if that call (or a callback you invoke) takes another lock, you've created a lock-ordering problem you can't see from this function.
✕ Don't
mu.Lock()
defer mu.Unlock()
resp, err := http.Get(url) // everyone waits for the networkif err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) // …and for the downloadif err != nil {
return err
}
cache[url] = body
returnnil
✓ Do
resp, err := http.Get(url) // slow work outside the lockif err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
mu.Lock()
cache[url] = body // lock only for the shared update
mu.Unlock()
returnnil
#8
JS: an await between the check and the act
The single-threaded promise doesn't protect you across await (§02, §04):
✕ Don't
if (!(awaitisTaken(name))) { // check…awaitcreateUser(name); // …another request did the same check meanwhile
}
total += awaitprice(item); // reads total BEFORE the await
✓ Do
try {
awaitcreateUser(name); // a UNIQUE constraint on name decides
} catch (err) {
if (err.code !== "23505") throw err; // 23505 = Postgres "unique_violation"return"name taken";
}
const p = awaitprice(item);
total += p; // read and write together, after the await
#9
Old Go: every goroutine sharing one loop variable
Before Go 1.22, a for loop had one variable for all iterations, so goroutines started in the loop raced on it and often all saw the last value:
for _, job :=range jobs {
gofunc() {
process(job) // Go ≤ 1.21: data race, often the same job N times
}() // Go ≥ 1.22: each iteration has its own job ✓
}
The fix applies when your go.mod says go 1.22 or later. In older modules, pass the value in: go func(job Job) { process(job) }(job).
#10
“The race detector is green, so we're safe”
go test -race only judges code that ran, in the orders that happened (§06). It's blind to logical races, database races and deadlocks. A green run means "no data race was observed", not "no race exists". Design the check-and-act steps to be indivisible first; use the tool to catch what slipped through.
Practitioner§13
Check yourself
Commit to an answer before you click. Q1 is covered in §02, Q2 in §03, Q3–Q4 in §11, Q5 in §08 and Q6 in §05.
Which change makes the transfer deadlock impossible?
functransfer(from, to *Account, amt int) {
from.mu.Lock()
defer from.mu.Unlock()
to.mu.Lock()
defer to.mu.Unlock()
from.balance -= amt
to.balance += amt
}
QUESTION 6 / 6
Optimistic locking: what does rowCount 0 mean?
const { rowCount } = await db.query(
"UPDATE docs SET body = $1, version = version + 1 WHERE id = $2 AND version = $3",
[newBody, id, versionWeRead]);
Answered 0 / 6
Foundations§14
Cheat sheet
🏁
Race condition = timing decides the resultCorrect for some orders, wrong for others. Hides in tests, appears under load.
🧮
count++ is read, add, writeThree steps. Two goroutines can both read before either writes → a lost update.
🔀
Data race ≠ race conditionData race: unsynchronized parallel memory access (Go). Race condition: wrong result from some order (everywhere, incl. JS across await).
🧱
Check + act = one stepA mutex, an atomic, synchronous JS, O_EXCL, one SQL statement, a UNIQUE constraint.
🗄️
Across servers, the database decidesUPDATE … SET x = x - 1 WHERE …, a version column, or SELECT … FOR UPDATE.
🔎
go test -race in CIEvery report is real. Silence only means no data race was observed.
♾️
Deadlock needs all four conditionsMutual exclusion, hold and wait, no preemption, circular wait. Break one.
🔢
Lock ordering beats clevernessAlways take locks in one global order. Timeouts recover; ordering prevents.
🎲
Retries need jitterSame back-off for everyone → livelock and thundering herds.
🩺
Hung? Look before you restartCPU 0% → goroutine dump (pprof ?debug=2, SIGQUIT). CPU 100% → profiler / debugger pause.
Symptom
Likely cause
First move
Total is a bit too low, differs per run
Read-modify-write race
go test -race; mutex or sync/atomic
Duplicate rows / double charges
Check-then-act, double submit
UNIQUE constraint + idempotency key
Stock or balance "forgets" changes
Lost update in the DB
One UPDATE, version column, or FOR UPDATE
fatal error: all goroutines are asleep
Every goroutine blocked
Read the stacks it prints
Requests hang, CPU idle
Deadlock or a wait with no timeout
Goroutine dump / Node diagnostic report
ERROR: deadlock detected (40P01)
Rows locked in different orders
Retry the transaction; sort IDs before locking
Busy CPU, nothing completes, conflict logs
Livelock
Add jitter; better, lock ordering
Some requests time out, most fine
Starvation / blocked event loop
Shorter critical sections; move CPU work off the event loop
Go
JavaScript
sync.Mutex + defer mu.Unlock()
No lock needed for sync code; async-mutex across await