The memory model
Learn how JavaScript shared memory handles data races, sequential consistency, spin locks, and lock-free compare-and-swap retries.
- 01Spot a data raceTell a plain read-add-write apart from one atomic step, and explain why its result can't be trusted.
- 02Use the order of atomic stepsExplain the one order that all atomic steps share, and hand over a value with an atomic ready flag.
- 03Pick a way to coordinateCompare a short spin lock, sleeping until a cell changes, and a compare-and-swap loop that tries again.
What the memory model promises
A worker is a second JavaScript program that runs at the same time as your page. Each worker has its own variables. Usually, nothing is shared.
A SharedArrayBuffer changes that. It is a block of bytes that the page and its workers can all read and write. When two programs touch the same bytes at the same time, you need rules for what each one sees. Those rules are the memory model.
A few words you will see in this lesson:
- An agent is one running JavaScript program, like the page or one worker.
- A cell is one numbered spot in shared memory, like
cells[0]. - An atomic operation is one step that no other agent can split in the middle. The
Atomicsfunctions, likeAtomics.add, are atomic. - A plain read or write is the normal kind, like
cells[0] = 5. It is not atomic.
JavaScript's memory model is the set of rules for shared memory. It says which values an agent is allowed to see when agents read and write the same cells. All atomic operations happen in one order that every agent agrees on. Plain reads and writes that clash have no such order. They can give surprising values, but JavaScript never crashes because of them.
This lesson follows SharedArrayBuffer & Atomics. That lesson showed the functions. This one explains the order rules behind them. The next lesson, Parallel patterns with workers, uses these rules to build groups of workers.
Data races
A data race happens when all of these are true:
- Two agents use the same shared cell.
- At least one of them writes to it.
- At least one of the two uses a plain read or write.
- Nothing decides which one goes first.
A line like cells[0] += 1 looks like one small step. It is really three steps: read the old value, add one, and write the new value. Another agent can act between those steps.
let balance = 100;const a = balance;const b = balance;balance = a + 50;balance = b + 50;console.log(balance);This code acts out two agents, A and B, in one fixed order. Here is what each line does:
- Line 1 starts the balance at 100.
- Line 2 is A reading the balance. A keeps 100.
- Line 3 is B reading the balance. B also keeps 100.
- Line 4 is A writing its deposit: 100 + 50, so 150.
- Line 5 is B writing its deposit. B still uses its old 100, so it also writes 150.
- Line 6 prints
150. One deposit of 50 is lost.
100 before either one writes. Each writes 100 + 50, so the balance ends at 150 instead of 200.We picked this order on purpose, so the lost deposit is easy to see. A real race does not always lose exactly one deposit. Real agents can run in other orders, and the rules allow even stranger values. So the rule is simple: never trust a result that comes from a data race.
Two people put money in at two ATMs at the same moment. Both screens show 100. Both add 50. Both save 150. One deposit disappears, because neither ATM held the balance while it made its change.
- In real life: Both screens show 100
- In JavaScript: Both agents read the same shared cell
- In real life: Each person adds a 50 deposit
- In JavaScript: Each agent adds to the value it read
- In real life: The second ATM writes over the first
- In JavaScript: A plain write can wipe out another write
- In real life: The bank lets only one change happen at a time
- In JavaScript: An atomic update happens as one step, in one order
Step through a lost deposit
The replay below runs the same balance code, one line at a time. It shows one bad order of plain reads and writes. It is a recording of this small model. It does not watch real workers, and it cannot tell you the order your workers will use.
Step through one fixed order that loses a deposit. This is a small model, not two real workers.
script
const a = balance;const b = balance;balance = a + 50;balance = b + 50;console.log(balance);Start with the two reads. A and B each keep 100 in their own variable. Then A writes 150. B still holds its old 100, so B also writes 150, over the top of A's value. The deposit was lost between B's read and B's write. Addition worked fine; the order was the problem.
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 agents have read and written.
Plain steps: A read 100; B read 100; A wrote 150; B wrote 150. Final balance: 150. This is one order you picked. Real workers may use a different one.
Now pick the steps in a different order. If A reads and writes before B reads, the plain model reaches 200. Switch to atomic deposits: each write becomes one Atomics.add, which reads, adds, and writes as one step. Then the two deposits always reach 200 in this model.
One shared order for atomic steps
Atomic operations follow a rule called sequential consistency. It means:
- All atomic operations happen one at a time, in one list.
- Every agent agrees on that list.
- Each agent's own atomic steps appear in the list in the same order as in its code.
Picture one shared notebook. Every atomic step is written on its own line, and every agent reads the same notebook.
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 makes one cell that holds a whole number. An
Int32Arrayis a list of whole numbers stored in bytes. - Line 2 uses
Atomics.storeto put 100 in cell 0. - Lines 3 and 4 each use
Atomics.addto add 50. No other atomic step can land in the middle of either add. - Line 5 uses
Atomics.loadto read the cell. It prints200.
Atomics.add is one step that nothing can split. Line 3 runs fully, then line 4, so the cell ends at 200.This small example uses a normal ArrayBuffer, so it can run in the page. With workers you would use a SharedArrayBuffer instead. The order rule is the same.
Atomic steps protect only the cells they touch. They do not protect your whole app. So give each cell one clear job, such as a flag, a counter, or a lock. Do not mix plain and atomic access on the same cell when agents compete for it. The specification recommends programs with no data races. In those programs, everything happens in an order you can follow.
The table below compares four ways to use shared cells. Read each row as: "this kind of access is this, follows this order rule, and is good for this job."
| Kind of access | What it is | Is there one order? | Use it for |
|---|---|---|---|
| Plain access | A normal read or write, like cells[0] = 5. | No. Agents don't agree on one order for plain steps. | Data that only one agent touches at a time. |
| Atomic access | An Atomics call on one cell of whole numbers. | Yes. All atomic steps happen in one order. | Flags and counters that agents compete for. |
| A lock | One atomic cell that lets one agent at a time in. | Yes. Taking and giving back the lock are atomic steps. | Guarding a few plain steps that must not be split. |
| Compare-and-swap loop | Swap in a new value only if the cell still holds the old one; else try again. | Yes. Each compare-and-swap is one atomic step. | Changing a cell without a lock, when trying again is fine. |
Share a value with a ready flag
Here is a common way for one agent to hand a value to another. It uses two cells:
- A data cell holds the value. It uses plain reads and writes.
- A ready flag cell says "the data is there". It uses atomic steps only. 0 means not ready, and 1 means ready.
The writer fills the data cell first. Then it sets the flag to 1. The reader checks the flag first. It reads the data only after it sees 1.
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]);}- Line 1 makes two cells.
- Lines 2 and 3 name them: cell 0 is
data, and cell 1 isready. - Line 4 puts 42 in the data cell, with a plain write.
- Line 5 uses
Atomics.storeto set the ready flag to 1. - Line 6 uses
Atomics.loadto read the flag. - Only when the flag is 1 does line 7 read the data. It prints
42.
data first, then sets ready to 1. The reader checks ready first, and reads 42 only after it sees 1.The flag works because both agents use atomic steps on it. That gives them one shared moment to agree on: "ready became 1". Everything the writer did before that moment is there for the reader after it sees the 1.
This is a small teaching example, not a full mailbox. A real worker setup also has to decide:
- Which agent is allowed to write each cell.
- Whether the data can be replaced, and when.
- How a waiting agent finds out that the flag changed.
For job details with many fields, use postMessage, which sends a copy of a value to a worker. Use shared cells only when a small shared value really helps.
Spin locks and the pause hint
A lock lets only one agent at a time use some shared data. The lock is one cell:
- 0 means unlocked: anyone may take it.
- 1 means locked: one agent is using the data.
A spin lock keeps trying to take the lock, again and again, instead of going to sleep. That can make sense for a very short wait inside a worker. But every failed try keeps the processor busy.
To take the lock, the code uses Atomics.compareExchange. This is a compare-and-swap: "if the cell still holds this value, swap in that value". It does the check and the swap as one step, and it gives back the value the cell had before.
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 to 3 call
Atomics.pauseonly if it exists.typeofgives the kind of a value as text, such as"function". - Lines 5 to 7 try to take the lock. If the cell holds 0, it becomes 1. The call gives back the old value, so the result is
trueonly when the old value was 0. - Lines 9 to 11 give the lock back by storing 0.
- Line 13 makes the lock cell. It starts at 0.
- Line 14 takes the lock and prints
true. - Line 15 gives it back, and line 16 prints
0.
Atomics.pause returns nothing you can use. It is only a hint to the processor: "I am waiting in a loop." On some computers it does nothing at all. It exists in Node 24 and newer browsers, but not in Node 22. That is why the code checks for it first.
For a long wait inside a worker, use Atomics.wait instead. It puts the worker to sleep until a cell changes. Never spin on the page's main thread: the page freezes while the loop runs.
Spin only for a short, limited number of tries, and only when you have measured that agents rarely wait for the lock. If the lock is still taken, wait or change the design. A loop that spins forever can steal processor time from the agent that holds the lock, so it takes even longer to give the lock back.
Try again instead of locking
A lock-free design changes shared cells without a lock. It uses compare-and-swap in a loop:
- Read the cell.
- Work out the new value.
- Swap it in, but only if the cell still holds the value you read.
- If another agent changed the cell first, go back to step 1.
One agent may have to try again. But each time a swap works, the work moves forward, so no agent is ever stuck waiting for a lock. Lock-free is a choice for some designs. You don't need it 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 1 makes one cell.
- Line 2 stores 3 in it.
- Line 3 reads the cell and keeps 3 in
observed. - Line 4 asks: "if the cell still holds 3, put 4 in".
- Nothing else changed the cell, so line 5 prints
4.
If another agent changes the cell first, the swap does nothing. It gives back the value that is really in the cell now. The loop reads again, works out a new value, and tries again.
Step through a compare-and-swap that has to try again. The change from another agent is written into the code, so the retry is easy to see.
script
const cell = new Int32Array(new ArrayBuffer(4));const observed = Atomics.load(cell, 0);Atomics.store(cell, 0, 4); // another agent changes the cellconst firstOld = Atomics.compareExchange(cell, 0, observed, observed + 1);const fresh = Atomics.load(cell, 0);Atomics.compareExchange(cell, 0, fresh, fresh + 1);console.log(Atomics.load(cell, 0));The replay adds a change from another agent on line 4, between the read and the first swap. The cell is now 4. So the first swap, on line 5, still expects 3. It gives back 4 and changes nothing. The retry reads 4 on line 6 and swaps in 5 on line 7. Line 8 prints 5.
3 to 4. Try 1 still expects 3, so nothing changes. Try 2 expects 4, so 5 goes in.Real lock-free lists and stacks have harder problems, such as items that are removed while another agent is still using them. That is why a single counter is the right first example.
Two real workers, exact totals
The examples so far act out one chosen order. Now for real workers. Node has a module called worker_threads that starts workers. The lesson's tests run two programs with it, and both always give the same answer.
In the first program, two workers each run Atomics.add one thousand times on one shared counter. The total is always 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();}- Line 1 loads the worker tools. The same file runs as the main program and as each worker;
isMainThreadtells them apart. - Line 3 makes 4 shared bytes: room for one counter.
- Line 4 starts two workers and gives each the shared bytes.
- Lines 5 and 6 wait until both workers stop, then print the counter.
- In each worker, line 8 makes a view of the shared bytes, and line 9 adds 1 a thousand times with
Atomics.add.
The test saves this program to a short-lived file, runs it, and checks 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();}The second program uses two cells. Cell 0 is the lock, and it only gets atomic steps. Cell 1 is a counter that gets a plain write.
- Line 10 keeps trying until it takes the lock.
- Line 11 adds 1 to the counter with a plain write. This is safe, because only the lock holder can be here.
- Line 12 gives the lock back.
Again, the two workers print exactly 2000.
Neither test tries to prove that a plain race loses a deposit. That result changes from run to run, so a test for it would sometimes pass and sometimes fail. The lesson uses the fixed-order model to show the danger, and real workers only for results that never change.
Pick the simplest tool that works
Start with messages. A group of workers can get each job with postMessage and send the result back the same way. Add shared memory only for a small value that many agents touch often, and only after you have designed and tested it. Good examples:
- A counter of finished jobs.
- A ready flag.
- The position in a small shared queue.
For every shared cell, answer these plain questions:
- What is this cell for? Write it down in a comment.
- Is it an atomic flag or plain data? Keep those in separate cells.
- Which agent is allowed to write it?
- When may a reader look at it?
- How does a waiting worker find out it changed?
These questions stop most shared-memory bugs before you think about speed.
cells[0]reads a shared valuecells[0] += 1in two workersAtomics.add(cells, 0, 1)- Set a ready flag with
Atomics.store - Keep calling
compareExchangeuntil you get the lock - Try again when compare-and-swap gives back a different value
Put each card in the group that describes it: a plain access, an atomic operation, or a way to coordinate agents.
Common misconceptions
- “A race always prints the wrong number.” A race can print the right number in one run and the wrong one in the next. You can't trust it either way.
- “A data race can make JavaScript crash or do anything.” No. The rules list which values a race may give. The values can surprise you, but the program keeps running.
- “Atomic protects everything near it.” An atomic step protects only the one cell it touches. How the other cells stay safe is up to your design.
- “
Atomics.pauseis a sleep.” It is only a hint while the code loops, and it may do nothing at all. - “Lock-free means no retries.” A compare-and-swap loop tries again whenever another agent changed the cell first.
The table below lists four terms that are easy to mix up. Read each row as: "this term means this, and it does not mean that."
| Term | What it means | What it does not mean |
|---|---|---|
| Data race | Two agents clash on one shared cell, and at least one uses a plain read or write. | That JavaScript crashes or can do anything. |
| Sequential consistency | All atomic steps happen in one order that every agent agrees on. | That plain reads and writes need no planning. |
Atomics.pause | A hint to the processor while code loops. Check that it exists first. | A sleep, a pause for other code, or a way to make code correct. |
| Lock-free | Changing cells with compare-and-swap and trying again, without a lock. | That it is always faster or always simpler. |
The specification gives careful advice: write programs with no data races. Use atomic steps every time agents compete for a cell. Never use plain access and atomic access on the same cell that agents compete for.
Practice exercises
This code acts out two agents in one fixed order. 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. In this fixed order, B's plain write goes over the top of A's write.
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. Each atomic add happens fully, one after the other, so both deposits count.
Which cell must the reader check with an atomic step before it reads the data?
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. The writer sets it with Atomics.store, and the reader reads it with Atomics.load. Once the reader sees 1, it may read the data.
Your worker may wait many milliseconds for a new job in a queue. Which tool is better than a loop that spins with no limit?
Use Atomics.wait in a worker: it sleeps until the cell changes. Use Atomics.waitAsync when the agent must keep doing other work while it waits.
In this example, no other agent touches the cell. What final number does it 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 cell still holds 3, as expected, so the compare-and-swap puts 4 in.
A photo website sends work to a group of workers. Each job has file details, options, and possible errors. What should it use to send those job details?
Use messages for job details and results. Add atomic cells only for a small shared counter or flag, and only when measuring shows it helps.
Check your understanding
For each question, find the shared cell first. Then ask: is each read or write plain or atomic, and which agent sees what?
Question 1 of 7Which of these can cause a data race?
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 steps?
Choose an answer to see the explanation.
Question 4 of 7Why does the ready flag example set the flag with an atomic step?
Choose an answer to see the explanation.
Question 5 of 7In a short spin loop, what should you do if
Atomics.pauseexists?Choose an answer to see the explanation.
Question 6 of 7A compare-and-swap expects 3, but another agent already changed the cell to 4. What does it give back?
Choose an answer to see the explanation.
Question 7 of 7Which sentence about JavaScript data races is true?
Choose an answer to see the explanation.
Key takeaways
- A data race is when agents clash on one shared cell and at least one uses a plain read or write. Never trust a result from a race.
- Atomic steps happen one at a time, in one order that every agent agrees on. This is called sequential consistency.
- To share a value, write the data first, then set a ready flag with
Atomics.store. The reader checks the flag withAtomics.loadbefore it reads the data. - Check that
Atomics.pauseexists before you call it. Use it only as a hint in a short spin. For long waits, useAtomics.wait. - A lock-free compare-and-swap loop tries again with the new value when another agent changed the cell first.
- Messages are usually the clearest way to send work to workers. Shared atomic cells suit small values that many agents touch.
Remember the one-liner.
Shared memory needs a clear order; Atomics gives small shared cells one order that every agent agrees on.
Coming next: Parallel patterns with workers. There, these small rules grow into groups of workers, message plans, and fast ways to move data.