The memory model
Learn how JavaScript shared memory handles data races, sequential consistency, spin locks, and lock-free compare-and-swap retries.
- 01Recognize a data raceSeparate an ordinary shared read-modify-write from one atomic operation and explain why timing cannot be trusted.
- 02Use atomic orderingExplain the single order seen by atomic operations and publish data with an atomic ready flag.
- 03Choose a coordination patternCompare a short spin lock, waiting, and a lock-free compare-and-swap retry for a small shared-state problem.
What the memory model promises
A worker has its own JavaScript variables, stack, and event loop. A SharedArrayBuffer is the unusual part: two agents can access the same bytes. The memory model says which shared-memory results JavaScript allows when agents read and write those bytes.
An agent is one JavaScript execution participant, such as a worker. A memory event is a shared read, shared write, or atomic read-modify-write. You do not need to draw the specification's event graphs every day. You do need to know when a shared update is ordinary and when it must be atomic.
JavaScript's memory model defines the allowed observations of shared memory. Atomic operations are sequentially consistent, while a program with a data race can observe surprising but specified values instead of having undefined behavior.
This lesson continues SharedArrayBuffer & Atomics. That lesson showed the API calls. Here we explain the ordering promise behind them. Coming next, Parallel patterns with workers puts small coordination rules into worker-pool designs.
Data races
A data race occurs when agents make conflicting shared accesses and the accesses are not all atomic. The usual danger is one agent writing while another reads or writes the same location. A source line such as cells[0] += 1 looks small, but its idea has three steps: read the old value, add one, and write the new value.
let balance = 100;const a = balance;const b = balance;balance = a + 50;balance = b + 50;console.log(balance);Line 1 starts the balance at 100. Lines 2 and 3 model two agents reading 100 before either writes. Line 4 writes A's deposit and line 5 writes B's deposit from B's old read. Line 6 prints 150. This is a teaching schedule chosen to make the lost update visible.
Do not turn that model into a claim that every race loses one deposit. A real schedule can happen differently, and the specification permits more surprising observations around racy shared accesses. The important rule is simpler: do not depend on a result from a data race.
Imagine two people depositing money at two ATMs at the same time. Both screens read 100, both add 50, and both write 150. One deposit disappears because neither ATM reserved the balance change as one action.
- In real life: Both screens show 100
- In JavaScript: Both agents read one shared cell
- In real life: Each adds a 50 deposit
- In JavaScript: Each computes from its local read
- In real life: One screen overwrites the other
- In JavaScript: A plain write can lose an update
- In real life: The bank allows one change at a time
- In JavaScript: An atomic update has one order
Where the analogy stops: A bank has business rules and records. The analogy only explains a conflicting read and write.
A lost update model
The next replay uses the same small balance program. It records one honest sequence of ordinary reads and writes. The replay is not an engine debugger and it does not predict when your workers will run; it makes one bad interleaving easy to inspect.
Replay an instrumented teaching model of one possible lost update. It is not a debugger attached to two workers.
script
const a = balance;const b = balance;balance = a + 50;balance = b + 50;console.log(balance);Start with the two reads. Both retained local values are 100. After A writes 150, B still has the earlier value in its own local variable. B's later write therefore replaces 150 with another 150. The missing deposit happened between the separate read and write, not because JavaScript forgot how addition works.
let balance = 100;let a;let b;// Choose each step below in the playground.a = balance;b = balance;balance = a + 50;balance = b + 50;console.log(balance);A read 100 | B read 100 | A wrote 150 | B wrote 150Both modeled agents read and wrote.
Plain model: A read 100; B read 100; A wrote 150; B wrote 150. Final balance: 150. This is a chosen schedule, not a prediction of every race.
Choose the steps in a different order. If A completes its read and write before B reads, the plain model reaches 200. Switch to atomic deposits and each write-shaped step becomes one Atomics.add, so two deposits always reach 200 in this model.
Sequential consistency
Sequential consistency means atomic shared-memory operations appear in one strict total order that every agent agrees on. Each agent keeps its own program order, and together the atomic events have one shared ordering. It is a strong, useful mental model: imagine everyone reading the same single register of atomic actions.
const cell = new Int32Array(new ArrayBuffer(4));Atomics.store(cell, 0, 100);Atomics.add(cell, 0, 50);Atomics.add(cell, 0, 50);console.log(Atomics.load(cell, 0));Line 1 creates one integer cell. Line 2 stores 100. Lines 3 and 4 each atomically add 50, with no other atomic operation splitting either add. Line 5 loads and prints 200. This tiny runnable example uses an ordinary ArrayBuffer so it works in a browser sandbox; the ordering idea is the same when workers use an integer view over shared bytes.
Atomic ordering is not a magic label for an entire application. Keep a clear layout: use a cell as a flag, counter, or lock word, and do not mix ordinary and atomic accesses to the same contested location. The specification recommends data-race-free programs because then the whole program has intuitive interleaving behavior.
| Thing | What it is | Ordering story | Use it for |
|---|---|---|---|
| Plain shared access | An ordinary typed-array read or write | No one total order across agents | Can participate in a data race |
| Atomic access | An Atomics operation on an integer cell | One strict total order for atomic events | Use for contested flags and counters |
| A lock | A shared atomic state that grants one owner | The lock operations are atomic | Protect a short non-atomic critical section |
| Lock-free retry | Compare-and-swap until the state matches | Each compare-and-swap is atomic | Avoids holding a lock, but may retry |
Publishing a message
A common pattern uses one ordinary data cell and one atomic flag. The writer fills the data first, then atomically says it is ready. The reader atomically checks the flag, then reads the data only after it observed ready. The flag is the small shared handshake.
const cells = new Int32Array(new ArrayBuffer(8));const data = 0;const ready = 1;cells[data] = 42;Atomics.store(cells, ready, 1);if (Atomics.load(cells, ready) === 1) { console.log(cells[data]);}Lines 1 through 3 create two cells and name their jobs. Line 4 puts 42 in the data cell. Line 5 atomically stores ready as 1. Line 6 atomically reads the flag; only when it sees 1 does line 7 print 42. The flag matters because it gives both agents a known atomic observation point.
This is a small teaching shape, not a complete mailbox. A real worker protocol needs ownership, whether data can be replaced, and a plan for waiting or notification. Use postMessage for rich job details; use shared cells when a small shared state is genuinely useful.
Atomics.pause and spin locks
A spin lock repeatedly tries to acquire a lock instead of sleeping. It can make sense for a very short wait in a worker, but spinning burns CPU while it fails. A lock word usually uses 0 for unlocked and 1 for locked, with compare-and-swap acquiring it.
function pauseHint() { if (typeof Atomics.pause === "function") Atomics.pause();} function tryLock(lock) { return Atomics.compareExchange(lock, 0, 0, 1) === 0;} function unlock(lock) { Atomics.store(lock, 0, 0);} const lock = new Int32Array(new ArrayBuffer(4));console.log(tryLock(lock));unlock(lock);console.log(Atomics.load(lock, 0));Lines 1 and 2 call Atomics.pause only when it exists. Lines 5 through 7 attempt to replace unlocked 0 with locked 1. Lines 9 through 11 release the lock by storing 0. Lines 14 through 17 show the tiny model printing true then 0.
Atomics.pause has no observable return value. It is a CPU hint that the thread is spinning and may do nothing on a platform. MDN lists it in Node 24 and newer browser versions, not Node 22, so feature detection is required. Prefer Atomics.wait for a long wait in an allowed worker, and never spin on the browser main thread because it freezes the page.
First try a short bounded spin only when you have measured low contention. If the lock is still held, wait or redesign the work. A loop that spins forever can prevent the lock holder from getting enough CPU time to release it.
Lock-free retry loops
Lock-free means an algorithm changes shared state with atomic operations and retry loops instead of holding a lock. One agent may need to retry, but the system can still make progress when some compare-and-swap succeeds. This is a design property, not a prize to chase for every counter.
const cell = new Int32Array(new ArrayBuffer(4));Atomics.store(cell, 0, 3);const observed = Atomics.load(cell, 0);Atomics.compareExchange(cell, 0, observed, observed + 1);console.log(Atomics.load(cell, 0));Line 2 stores 3. Line 3 remembers that value. Line 4 asks to replace 3 with 4 only if the cell is still 3. With no competing change, line 5 prints 4. When another agent changes the value first, compare-and-swap returns the new old value and the algorithm reads, recalculates, and retries.
Replay an instrumented compare-and-swap retry. The model makes the competing update explicit so the retry is easy to see.
script
const cell = new Int32Array(new ArrayBuffer(4));const observed = Atomics.load(cell, 0);Atomics.compareExchange(cell, 0, observed, observed + 1);console.log(Atomics.load(cell, 0));The replay inserts a competing atomic write between the first read and first compare-and-swap. The stale attempt returns 4 and changes nothing. The retry reads 4, swaps in 5, and completes. Real lock-free stacks add hazards such as allocation, removed nodes, and version changes, so a plain atomic counter is the right first example.
Two workers, exact totals
Teaching models explain one schedule. Node's worker_threads lets the lesson test demonstrate two stable runtime facts. Two workers each run one thousand Atomics.add operations on a SharedArrayBuffer counter, and the final total is exactly 2000.
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");if (isMainThread) { const buffer = new SharedArrayBuffer(4); const workers = [0, 1].map(() => new Worker(__filename, { workerData: buffer })); Promise.all(workers.map((worker) => new Promise((done) => worker.once("exit", done)))) .then(() => console.log(new Int32Array(buffer)[0]));} else { const cell = new Int32Array(workerData); for (let index = 0; index < 1000; index += 1) Atomics.add(cell, 0, 1); parentPort.close();}The main thread creates shared bytes on line 3 and waits for both workers to exit. Each worker runs the atomic increment on line 9 one thousand times. The child-process test writes the program to a temporary file and verifies that it prints 2000.
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");if (isMainThread) { const buffer = new SharedArrayBuffer(8); const workers = [0, 1].map(() => new Worker(__filename, { workerData: buffer })); Promise.all(workers.map((worker) => new Promise((done) => worker.once("exit", done)))) .then(() => console.log(new Int32Array(buffer)[1]));} else { const cells = new Int32Array(workerData); for (let index = 0; index < 1000; index += 1) { while (Atomics.compareExchange(cells, 0, 0, 1) !== 0) {} cells[1] += 1; Atomics.store(cells, 0, 0); } parentPort.close();}This second program reserves cell 0 for the atomic lock and cell 1 for the ordinary counter. Line 11 acquires the lock, line 12 performs the non-atomic increment while it is protected, and line 13 releases it. The two workers again print exactly 2000.
Neither probe tries to prove a non-atomic race will lose updates. That would be unstable evidence. The lesson uses the deterministic model to teach the risk and real workers only to prove the reliable atomic and lock outcomes.
Choose the smallest coordination tool
Start with messages. A worker pool can receive jobs with postMessage and send a result back the same way. Add shared memory only for a small hot state such as a completed-job counter, a ready flag, or a compact ring-buffer index that you have designed and tested.
Name each cell's purpose in code or documentation. Keep atomic flags and ordinary data in separate cells. Decide who writes each field, when a reader may inspect it, and how a waiting worker wakes. These plain questions prevent most shared-memory bugs before performance tuning starts.
cells[0]reads a shared valuecells[0] += 1across workersAtomics.add(cells, 0, 1)- Atomically store a ready flag
- Retry
compareExchangeto acquire a lock - Retry after compare-and-swap returns another value
Place each card with the kind of operation or coordination pattern it describes.
Common misconceptions
- “A race always prints the wrong number.” It can look correct in one run and still be unreliable.
- “Data races mean JavaScript can crash with undefined behavior.” ECMAScript specifies allowed executions, even though values can surprise you.
- “Atomic means every related object is protected.” An atomic operation covers its integer cell; the surrounding protocol is your design.
- “pause is a sleep.” It is only a spinning hint and may have no observable effect.
- “Lock-free means no retries.” A compare-and-swap algorithm may retry whenever another agent wins first.
| Term | What it means | Do not confuse it with |
|---|---|---|
| Data race | Conflicting shared accesses without the needed atomic ordering | A JavaScript crash or undefined behavior |
| Sequential consistency | A single shared order for atomic operations | A promise that ordinary reads never need design |
| Atomics.pause | A feature-detected CPU spin hint | A sleep, yield, or correctness mechanism |
| Lock-free | An algorithm that retries atomic changes without a lock | Always faster or automatically simpler |
The specification's practical advice is conservative: keep programs data-race free. Use atomic operations consistently for contested coordination and do not access the same location with mismatched atomic and non-atomic rules.
Practice exercises
Read the teaching model. What number does it print?
let balance = 100;
const a = balance;
const b = balance;
balance = a + 50;
balance = b + 50;
console.log(balance);let balance = 100;
const a = balance;
const b = balance;
balance = a + 50;
balance = b + 50;
console.log(balance);It prints 150. The second plain write overwrites the first in this chosen schedule.
What is the final value after the two atomic deposits?
const cell = new Int32Array(new ArrayBuffer(4));
Atomics.store(cell, 0, 100);
Atomics.add(cell, 0, 50);
Atomics.add(cell, 0, 50);
console.log(Atomics.load(cell, 0));const cell = new Int32Array(new ArrayBuffer(4));
Atomics.store(cell, 0, 100);
Atomics.add(cell, 0, 50);
Atomics.add(cell, 0, 50);
console.log(Atomics.load(cell, 0));It prints 200, because both atomic additions are included.
Which cell must the reader observe atomically before it reads the data value?
const cells = new Int32Array(new ArrayBuffer(8));
const data = 0;
const ready = 1;
cells[data] = 42;
Atomics.store(cells, ready, 1);
if (Atomics.load(cells, ready) === 1) {
console.log(cells[data]);
}The ready flag is atomically stored and atomically read. It is the shared ordering point before the reader reads data.
Your worker may wait many milliseconds for a queue item. Which tool is better than an unbounded spin loop?
Use Atomics.wait in an allowed worker for a long blocking wait, or Atomics.waitAsync when the current agent must stay responsive.
What final number does this no-contention compare-and-swap example print?
const cell = new Int32Array(new ArrayBuffer(4));
Atomics.store(cell, 0, 3);
const observed = Atomics.load(cell, 0);
Atomics.compareExchange(cell, 0, observed, observed + 1);
console.log(Atomics.load(cell, 0));const cell = new Int32Array(new ArrayBuffer(4));
Atomics.store(cell, 0, 3);
const observed = Atomics.load(cell, 0);
Atomics.compareExchange(cell, 0, observed, observed + 1);
console.log(Atomics.load(cell, 0));It prints 4: the compare-and-swap sees 3 and replaces it with 4.
A photo-processing site needs workers to receive file details, options, and errors. What should it use for those complex job details?
Use messages for job details and results. Add atomics only for a compact shared counter or flag when measurement shows it is useful.
Check your understanding
Trace one source line into its actual read, write, or atomic action. Then ask which agent can observe which shared state.
Question 1 of 7Which situation is a data-race risk?
Choose an answer to see the explanation.
Question 2 of 7What does this atomic deposit program print?
Read the code, then predictconst cell = new Int32Array(new ArrayBuffer(4)); Atomics.store(cell, 0, 100); Atomics.add(cell, 0, 50); Atomics.add(cell, 0, 50); console.log(Atomics.load(cell, 0));Choose an answer to see the explanation.
Question 3 of 7What does sequential consistency give atomic operations?
Choose an answer to see the explanation.
Question 4 of 7Why does the message example use an atomic ready flag?
Choose an answer to see the explanation.
Question 5 of 7What should a short spin loop do when
Atomics.pauseexists?Choose an answer to see the explanation.
Question 6 of 7What does compare-and-swap return when its expected value is stale?
Choose an answer to see the explanation.
Question 7 of 7Which statement about JavaScript data races is accurate?
Choose an answer to see the explanation.
Key takeaways
- A data race is conflicting shared access without the required atomic ordering; do not depend on any result from it.
- Atomic operations are sequentially consistent: agents agree on one strict order for those operations.
- Publish ordinary data before atomically storing a ready flag, then atomically read that flag before consuming data.
- Feature-detect `Atomics.pause`; use it only as a short spin hint and prefer waiting for long waits.
- Lock-free compare-and-swap loops retry from the current value when another agent changes the cell first.
- Messages are usually the clearest tool for rich worker protocols; shared atomics suit small contested state.
Remember the one-liner.
Shared memory needs a clear ordering rule; Atomics gives small shared cells one that every agent can agree on.
Coming next: Parallel patterns with workers, where these small rules become worker pools, message protocols, and efficient data movement.