SharedArrayBuffer & Atomics
Learn how SharedArrayBuffer and Atomics let workers share counters safely, coordinate waiting, and meet browser isolation rules.
- 01Share bytes between workersExplain what a SharedArrayBuffer shares, what stays private, and why you read it through a typed array such as Int32Array.
- 02Pick the right Atomics toolUse load, store, add, sub, exchange, and compareExchange to read and change one shared number safely.
- 03Make workers wait for each otherTell apart wait, notify, and waitAsync, and name the two headers a browser page needs before it can share memory.
One memory area, many workers
An agent is one place that runs JavaScript. The page is one agent. Each worker is another agent. Each agent has its own variables and its own call stack.
Normally, a page and a worker talk by sending messages. Each message is a copy. If the worker changes its copy, the page does not see the change.
Shared memory is different. Two or more agents read and write the very same bytes. A write by one agent shows up for the others.
Only those bytes are shared. These stay private to each agent:
- variables
- objects
- functions
- the call stack
Shared memory is a tool for a small job, such as one counter that several workers update. You plan which number goes where. You also decide which updates need a safe rule.
Atomics is a built-in object. Its operations read or change one whole number in a typed array (a fixed-size list of numbers; the next section shows one). Each operation happens as one step that can't be split. No other atomic operation can see it half done.
This lesson comes after How workers work. The next lesson is The memory model. Here you learn the small, practical tools. The next lesson explains the rules about order behind them.
Shared bytes with SharedArrayBuffer
A SharedArrayBuffer is a block of raw bytes that several agents can share. It does not hold JavaScript objects. It holds only bytes.
To read the bytes as whole numbers, put a view over them. A view is a typed array, such as Int32Array. An Int32Array reads every 4 bytes as one whole number. We call each of those numbers a cell. Index 0 is the first cell.
Start with an ordinary buffer. It is not shared, so it runs anywhere. It shows the Atomics calls before shared memory comes in.
1const counter = new Int32Array(new ArrayBuffer(4));2Atomics.add(counter, 0, 5);3console.log(Atomics.load(counter, 0));Line by line:
- Line 1 makes one cell. A new cell starts at
0. - Line 2 uses
Atomics.addto add5to cell0. - Line 3 uses
Atomics.loadto read the cell. It prints5.
A shared buffer changes where the bytes live. You still use views the same way. Two views over one shared buffer are not two copies. They are two names for the same bytes. A write through one name shows up through the other.
1const bytes = new SharedArrayBuffer(4);2const pageView = new Int32Array(bytes);3const workerView = new Int32Array(bytes);4pageView[0] = 3;5workerView[0] = 8;6console.log(pageView[0], workerView[0]);pageView and workerView are two names for the same bytes. The second write replaces the first, so both names read 8.Line by line:
- Line 1 makes 4 shared bytes.
- Lines 2 and 3 make two views over those same bytes. The names say who would use each one: the page and a worker.
- Line 4 writes
3throughpageView. - Line 5 writes
8throughworkerView. This replaces the3. - Line 6 prints
8 8. Both views read the same cell.
In a real app, the page and the worker each make their own view, the same way. Run this code in Node.js or in a page with cross-origin isolation (a browser safety setting you will meet below). The lesson editor has neither, so SharedArrayBuffer does not exist there.
The table compares three ways data can reach another agent, plus a normal variable. Read each row from left to right.
| What you use | What the other agent gets | What happens after a later write |
|---|---|---|
| A normal message | A copy of the values. | Each side changes its own copy. The other side doesn't see it. |
| A transfer (handing over) | The thing itself, such as an ArrayBuffer. | The sender can no longer use it. |
| A SharedArrayBuffer | The same block of bytes. | Every view sees the write. |
| A normal variable | Nothing. It never leaves its agent. | Only its own agent sees the write. |
Browsers only allow shared memory on isolated pages. So check first, before you make a shared buffer.
1if (typeof SharedArrayBuffer === "function" && crossOriginIsolated) {2 const counter = new Int32Array(new SharedArrayBuffer(4));3 Atomics.add(counter, 0, 5);4 console.log(Atomics.load(counter, 0));5} else {6 console.log("Shared memory needs cross-origin isolation");7}Line by line:
- Line 1 checks two things. Does
SharedArrayBufferexist? IscrossOriginIsolatedtrue? - Lines 2 and 3 make shared bytes, a view, and add 5.
- Line 4 prints
5on an isolated page. - On any other page, line 6 prints a short message instead of throwing an error.
Picture a whiteboard in a room. Everyone can read the number on it. If two people try to change it at the same time, they need a rule. One person writes at a time.
- In real life: One whiteboard is in the room
- In JavaScript: One SharedArrayBuffer holds bytes
- In real life: Everyone sees the same number
- In JavaScript: Every view reads the same cell
- In real life: One marker changes the number
- In JavaScript: An atomic operation changes a cell
- In real life: Each person has a private notebook
- In JavaScript: Each agent keeps private variables
How two workers can lose an update
count = count + 1 looks like one step. It is really three steps:
- read the count
- add one
- write the new count back
Two workers can both read 0 before either one writes. Both work out 1. Both write 1. The second write replaces the first. The team wanted two increases, but the count is 1.
This is called a lost update. It is why a shared counter needs more than shared bytes. In real code, the timing changes from run to run, so the bug may not show up when you test. This lesson uses a model instead. The model always picks the same order, so you can see the problem every time.
1let count = 0;2const firstRead = count;3const secondRead = count;4count = firstRead + 1;5count = secondRead + 1;6console.log(count);0 before either one writes. Each writes 0 + 1, so the count ends at 1 instead of 2.Line by line:
- Line 1 starts the count at
0. - Lines 2 and 3 are the two reads. Both workers save
0. - Lines 4 and 5 are the two writes. Both write
0 + 1, which is1. - Line 6 prints
1.
The model picks the bad order on purpose: both reads first. Real code does not lose an update every time. It only can.
Step through a model of a lost update. Real code does not lose an update every time; it only can.
script
2const firstRead = count;3const secondRead = count;4count = firstRead + 1;5count = secondRead + 1;6console.log(count);Step through the model. Stop at each read and each write. This replays the lesson's own code; it is not a real debugger. Watch the second worker: it still holds the old 0 when it writes.
1let count = 0;2const firstRead = count;3const secondRead = count;4count = firstRead + 1;5count = secondRead + 1;6console.log(count);- first reads 0
- second reads 0
- first writes 1
- second writes 1
const count = new Int32Array(new ArrayBuffer(4));
Atomics.add(count, 0, 1);
Atomics.add(count, 0, 1);
console.log(Atomics.load(count, 0));The count ends at 1. Both workers read 0 before either one wrote. So both wrote 1, and the second write replaced the first.
Try another order. If one worker finishes before the other reads, the count ends at 2. If both read first, both use the old 0, and the count ends at 1. Only the order changes. Nothing else does.
Safe one-step updates with Atomics
Use an atomic operation when several agents change the same cell. It runs as one step. No other atomic operation can slip in between its read and its write.
This keeps one cell safe. It does not make a whole app safe. You still decide what each cell means and in what order agents use them.
1const score = new Int32Array(new ArrayBuffer(4));2Atomics.store(score, 0, 3);3console.log(Atomics.load(score, 0));Line 1 makes one cell. Line 2 stores 3 in it. Line 3 reads it with Atomics.load and prints 3. Use load when you want to know the current value.
1const score = new Int32Array(new ArrayBuffer(4));2console.log(Atomics.store(score, 0, 3));3console.log(Atomics.load(score, 0));Line 2 writes 3 with Atomics.store. The call gives back the value it stored, so it prints 3. Line 3 reads the cell again and also prints 3. A store only changes the cell. It does not wake up a worker that is waiting.
1const score = new Int32Array(new ArrayBuffer(4));2Atomics.store(score, 0, 3);3console.log(Atomics.add(score, 0, 2));4console.log(Atomics.load(score, 0));Line 2 sets the cell to 3. Line 3 adds 2. But Atomics.add gives back the old value, so it prints 3. Line 4 reads the cell after the change and prints 5. Getting the old value back is useful for counters, and for compare and swap later on.
1const score = new Int32Array(new ArrayBuffer(4));2Atomics.store(score, 0, 3);3console.log(Atomics.load(score, 0));4console.log(Atomics.add(score, 0, 2));5console.log(Atomics.sub(score, 0, 1));6console.log(Atomics.exchange(score, 0, 9));7console.log(Atomics.compareExchange(score, 0, 9, 12));8console.log(Atomics.load(score, 0));Line by line:
- Line 1 makes one cell.
- Line 2 stores
3. - Line 3 reads it and prints
3. - Line 4 adds
2. It prints the old value,3. The cell is now5. - Line 5 subtracts
1. It prints the old value,5. The cell is now4. - Line 6 swaps in
9withexchange. It prints the old value,4. - Line 7 expects
9, finds9, and puts in12. It prints the old value,9. - Line 8 reads the cell and prints
12.
The table lists each tool. Read a row as: the tool, what it does to the cell, and what the call gives back.
| Tool | What it does to the cell | What it gives back |
|---|---|---|
load | Reads the cell. | The value in the cell right now. |
store | Writes a new value into the cell. | The value it wrote. |
add and sub | Adds to or subtracts from the cell. | The old value, from before the change. |
exchange | Puts in a new value, no matter what is there. | The old value. |
compareExchange | Puts in a new value only if the cell holds the value you expect. | The old value, whether or not it changed the cell. |
These tools work on whole-number typed arrays only, not on objects. For rich data or rare updates, send messages instead. A shared cell is useful because its job is small. That makes it easy to explain, check, and test.
Compare and swap: change it only if it still matches
Compare and swap means: "Change the cell only if it still holds the value I saw." In JavaScript, this is Atomics.compareExchange. It takes four inputs:
- the typed array
- the cell index
- the value you expect to find
- the new value to put in
It checks and replaces in one atomic step. It always gives back the old value, whether the check passed or not.
1const score = new Int32Array(new ArrayBuffer(4));2Atomics.store(score, 0, 3);3console.log(Atomics.compareExchange(score, 0, 3, 8));4console.log(Atomics.load(score, 0));3, so 8 goes in. The call gives back the old value, 3.Line 2 stores 3. Line 3 expects 3 and finds 3. So it puts in 8 and prints the old value, 3. Line 4 reads the cell and prints 8. The check and the write are one step. They are not an if check followed later by a separate write.
Step through a real compare and swap. The old value it gives back tells you whether the cell held the value you expected.
script
2Atomics.store(cell, 0, 3);3const old = Atomics.compareExchange(cell, 0, 3, 8);4console.log(old, Atomics.load(cell, 0));The replay shows the case where the check passes. Two values matter here, and they have different jobs:
- The old value,
3, is what the call gives back. - The cell's new value,
8, is what stays in memory.
If the old value is not what you expected, another worker changed the cell first. Then you read again and try again.
1const score = new Int32Array(new ArrayBuffer(4));2Atomics.store(score, 0, 3);3let seen = Atomics.load(score, 0);4while (Atomics.compareExchange(score, 0, seen, seen + 1) !== seen) {5 seen = Atomics.load(score, 0);6}7console.log(Atomics.load(score, 0));Line by line:
- Lines 1 and 2 make a cell holding
3. - Line 3 reads the score and saves it in
seen. - Line 4 tries to replace
seenwithseen + 1. If the old value it gets back is notseen, another worker changed the cell first. - In that case, line 5 reads the cell again, and the loop tries again.
- Here no other worker writes, so the first try works. Line 7 prints
4.
Keep these loops small. Use them for a quick change to one cell, not for long work. The next lesson, The memory model, explains the order rules that make this pattern safe.
Waiting for another worker
Adding to a counter happens right away. But sometimes a worker must pause until another agent changes a cell. For example, cell 0 could mean "is a job ready?"
A worker could keep reading the cell in a loop. That wastes work. Instead, Atomics.wait lets the worker sleep while the cell still holds a value you name. It wakes when another agent calls Atomics.notify.
1// worker.js: never call Atomics.wait on the page's main thread.2const cells = new Int32Array(sharedBuffer);3const result = Atomics.wait(cells, 0, 0);4console.log(result); // "ok" if woken, "not-equal" if the value already changed5 6// the page or another worker7Atomics.store(cells, 0, 1);8Atomics.notify(cells, 0, 1);0. Another agent stores 1 first, then notifies, and the worker wakes with ok.This is a sketch of two files. It can't run in the lesson editor. Line by line:
- Line 2 makes a view over the shared bytes. The worker got
sharedBufferin a message. - Line 3 sleeps while cell
0holds0. - Line 4 prints the result after the worker wakes.
- Line 7, in another agent, stores
1first. - Line 8 then calls
notifyto wake one waiting worker.
The result is one of three words:
"ok": the worker was asleep and got woken up."not-equal": the cell had already changed before the worker started waiting, so it did not sleep at all."timed-out": you gave a time limit, and it ran out before anyone woke the worker.
notify does not write a value. It only wakes workers. So always store the new value first, then notify.
The main thread is the page's own agent. It also draws the page and handles clicks. If it slept, the page would freeze, so browsers don't allow Atomics.wait there. Use Atomics.waitAsync instead. It does not sleep.
1const cells = new Int32Array(sharedBuffer);2const result = Atomics.waitAsync(cells, 0, 0);3if (result.async) {4 result.value.then((status) => console.log(status));5} else {6 console.log(result.value); // "not-equal" or "timed-out"7}Line by line:
- Line 2 calls
waitAsync. It gives back an object right away instead of sleeping. - If
result.asyncis true, line 4 gets a promise. Later it prints"ok"when woken, or"timed-out"if a time limit runs out. - Otherwise, line 6 prints the answer right away:
"not-equal", or"timed-out"when the time limit is zero.
The table compares the three tools. Read a row as: the tool, what it does, and where you may use it.
| Tool | What it does | Where it belongs |
|---|---|---|
Atomics.wait | Puts the agent to sleep while the cell holds a value you name. | In a worker only. The page's main thread may not sleep. |
Atomics.notify | Wakes agents that sleep on the cell. It gives back how many woke. | In any agent, right after a store. |
Atomics.waitAsync | Gives back an answer now, or a promise of an answer later. | Anywhere, including the main thread, because it never sleeps. |
Give each cell value a meaning. Here, 0 means "no job" and 1 means "job ready". The store sets the meaning. The notify tells a sleeping worker to look again. Waking up does not prove the value is still the one the worker wanted, so it should read the cell again.
The browser's safety rule: cross-origin isolation
A browser lets a page make and send shared buffers only if the page is cross-origin isolated. That means the page is walled off from pages and files of other sites, unless they agree to it.
Why? Shared memory can be used as a very exact timer. Attackers can use exact timers to read secrets they should not see. One known attack of this kind is called Spectre. So the page has to opt in to a stronger wall first.
The page opts in with two response headers. A response header is a short line of text the server sends along with the page.
1Cross-Origin-Opener-Policy: same-origin2Cross-Origin-Embedder-Policy: require-corp3 4if (crossOriginIsolated) {5 const bytes = new SharedArrayBuffer(16);6 console.log(bytes.byteLength);7}Line by line:
- Line 1 sets Cross-Origin-Opener-Policy (COOP) to
same-origin. Windows from other sites that open this page, or that it opens, lose their link to it. - Line 2 sets Cross-Origin-Embedder-Policy (COEP) to
require-corp. The page may only load files from other sites that agree to it. Another allowed value iscredentialless. - Line 4 checks
crossOriginIsolated. It is true only when the browser accepted both headers. - Lines 5 and 6 make a shared buffer of 16 bytes and print
16.
COEP changes how files from other sites load on your page. So it is a decision for the whole site, not one worker. Before you turn it on, check every script, image, frame, and API your page loads from other sites. The browser security model lesson explains COOP and COEP in more depth.
A real use: counting finished jobs
A good real design stays small. Here is one. A group of workers gets jobs through messages. When a worker finishes a job, it adds one to a shared counter cell. The page reads that one cell to show progress. It does not need to ask every worker for a status message.
1const progress = new Int32Array(new SharedArrayBuffer(4));2 3function finishJob() {4 Atomics.add(progress, 0, 1);5}6 7finishJob(); // worker one finishes8finishJob(); // worker two finishes9console.log(Atomics.load(progress, 0));Line by line:
- Line 1 makes one shared cell for progress.
- Lines 3 to 5 are what a worker does when a job is done: add
1to the cell. - Lines 7 and 8 stand in for two workers, each finishing a job.
- Line 9 reads the cell and prints
2.
This model runs in a single agent, and you can run it in Node.js. In a real app, each worker gets the buffer in its first message. In a browser, the page must be cross-origin isolated. In Node.js, you pass the buffer to each worker through worker_threads. The lesson editor has no shared buffer, so this code can't run there.
Give each cell one job, and write down who writes to it. For example:
- cell
0: how many jobs are done - cell
1: how many jobs there are in total - cell
2: a "ready" flag
Store a new value before you notify a worker. Let normal messages carry names, errors, and large results.
SharedArrayBufferAtomics.addAtomics.compareExchangeAtomics.waitAtomics.notifyCross-Origin-Embedder-Policy
Sort each card: does it share or change memory, help agents wait for each other, or set up the browser?
In Node.js, two real workers can each add 1 one thousand times to one shared counter. The count always ends at exactly 2000. The lesson's tests check this. They also check the compare-and-swap values and a real wait and notify. They don't rely on a lost update showing up, because that depends on luck.
Common misconceptions
Shared memory does a small job on purpose. Most mistakes come from two wrong ideas. One is that it shares objects. The other is that one safe cell update makes the whole app safe. Keep your list of cells short enough that someone else can explain what each one is for.
- "SharedArrayBuffer shares all my variables." No. Only the bytes inside the buffer are shared. Variables and objects stay private to each agent.
- "Atomics makes any code safe." No. It makes one cell update safe. You still have to plan what each cell means and in what order agents use them.
- "
Atomics.waitis likeawaiton the main thread." No.waitputs the agent to sleep, so it is for workers.waitAsyncdoes not sleep, so the page can use it. - "
notifychanges the value." No. It only wakes workers. Store the new value first, then notify. - "COOP and COEP are optional." No. A browser page can't share memory without cross-origin isolation, and that needs both headers.
Messages are still the safe first choice. Use shared memory only when planning and measuring show a small shared-number problem. Some examples: a counter for a group of workers, a "ready" flag, or memory shared with WebAssembly.
Practice exercises
Each task is about one tool or one browser rule. For code, follow the value in the cell line by line before you answer.
Read the program and type what it prints.
const counter = new Int32Array(new ArrayBuffer(4));
Atomics.add(counter, 0, 5);
console.log(Atomics.load(counter, 0));const counter = new Int32Array(new ArrayBuffer(4));
Atomics.add(counter, 0, 5);
console.log(Atomics.load(counter, 0));It prints 5. The add changes 0 to 5, and then the load reads 5.
Type the two values this program prints.
const cell = new Int32Array(new ArrayBuffer(4));
Atomics.store(cell, 0, 4);
console.log(Atomics.compareExchange(cell, 0, 3, 9));
console.log(Atomics.load(cell, 0));const cell = new Int32Array(new ArrayBuffer(4));
Atomics.store(cell, 0, 4);
console.log(Atomics.compareExchange(cell, 0, 3, 9));
console.log(Atomics.load(cell, 0));It prints 4 and then 4. The cell holds 4, not the expected 3, so nothing changes. The call still gives back the old value, 4.
In the playground, pick the order where both workers read first. What final count does the model show?
The count ends at 1. Both workers read 0, so the second write of 1 replaces the first write of 1.
In a browser app, where may you use Atomics.wait, which puts the agent to sleep?
Use Atomics.wait in a worker. The page's main thread must not sleep, so it uses Atomics.waitAsync instead.
Which two response headers turn on cross-origin isolation?
Send COOP (Cross-Origin-Opener-Policy) and COEP (Cross-Origin-Embedder-Policy). Then check crossOriginIsolated before you make a shared buffer.
Several workers share one counter of finished jobs. Which Atomics tool should each worker call when it finishes a job?
Use Atomics.add to add one to the shared counter. Send the job details in normal messages.
Check your understanding
For each question, first ask what it is about. Is it the shared bytes, one atomic tool, waiting, or the browser setup? Then follow the cell's value.
Question 1 of 8What does a SharedArrayBuffer share?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictPop out in the code editor (opens in a new tab)JavaScriptconst counter = new Int32Array(new ArrayBuffer(4)); Atomics.add(counter, 0, 5); console.log(Atomics.load(counter, 0));Choose an answer to see the explanation.
Question 3 of 8What does Atomics.add give back?
Choose an answer to see the explanation.
Question 4 of 8When does compare and swap put in the new value?
Choose an answer to see the explanation.
Question 5 of 8Why can't the page's main thread use Atomics.wait?
Choose an answer to see the explanation.
Question 6 of 8Which headers normally make a page cross-origin isolated?
Choose an answer to see the explanation.
Question 7 of 8A worker sends
{ score: 3 }with a normal postMessage. Later it changes its own object. What does the receiver have?Choose an answer to see the explanation.
Question 8 of 8A worker expects a cell to hold 0, but it already holds 1. What can waitAsync give back right away?
Choose an answer to see the explanation.
Key takeaways
- A
SharedArrayBuffershares bytes. Variables, objects, and call stacks stay private to each agent. - Read shared bytes as number cells through a view such as
Int32Array. - When several agents change one cell, use Atomics, so each change is one step that can't be split.
compareExchangechanges a cell only if it still holds the value you expect.waitandnotifylet a worker sleep and wake.waitAsyncwaits without freezing the page.- A browser page needs COOP and COEP, and
crossOriginIsolatedmust be true.
Remember the one-liner.
Shared memory shares bytes; Atomics gives each shared number cell a safe, one-step way to change.
Coming next: The memory model. It explains when one agent sees another agent's writes, and in what order.