SharedArrayBuffer & Atomics
Learn how SharedArrayBuffer and Atomics let workers share counters safely, coordinate waiting, and meet browser isolation rules.
- 01Share a buffer safelyExplain what workers share through a SharedArrayBuffer and why a typed array view is needed.
- 02Choose atomic operationsUse load, store, add, sub, exchange, and compareExchange for small shared integer cells.
- 03Coordinate agentsDistinguish blocking wait, notify, waitAsync, and the browser headers needed for shared memory.
One memory area, many workers
Shared memory means two or more JavaScript agents can read and write the same bytes. A page and a worker normally exchange copied messages. A shared buffer is different: each agent has its own JavaScript world, but chosen typed-array views point at one backing block of bytes.
This is an expert tool for a small coordination problem, not a way to share every object. You choose a compact layout, give each integer cell a job, and decide which contested updates need an atomic rule. Variables, objects, functions, and call stacks remain private to each agent.
Atomics is a namespace of operations that read or update one integer typed-array cell as one indivisible action. Another atomic operation cannot observe an unfinished half of that operation.
This lesson continues from How workers work and prepares for The memory model. First learn the small practical pieces; the next lesson explains the ordering guarantees underneath them.
SharedArrayBuffer
A SharedArrayBuffer is raw bytes that compatible agents can share. It is not an array of JavaScript objects. Put an Int32Array view over it when you need integer cells; index zero then names the first four-byte signed integer.
1const counter = new Int32Array(new ArrayBuffer(4));2Atomics.add(counter, 0, 5);3console.log(Atomics.load(counter, 0));Line 1 creates an ordinary integer cell at zero. Line 2 atomically adds five. Line 3 loads the final value and prints 5. This plain typed-array example runs without browser isolation, so it teaches the operation before shared browser memory enters the picture.
A shared buffer changes the storage, not the view syntax. Two views are not equal arrays with equal starting values. They are windows onto the exact same bytes, so a write through one is visible 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]);Line 1 allocates shared bytes. Lines 2 and 3 make the two views. Line 4 writes three, line 5 overwrites it with eight through the second view, and line 6 prints 8 8. A page and worker would create the same kind of separate views. Run this in Node.js or a cross-origin-isolated page; the lesson editor does not provide that environment or define SharedArrayBuffer.
| Mechanism | What crosses agents | What later writes mean |
|---|---|---|
| Normal message | A structured copy of values | The sender and receiver change separate copies. |
| Transferable | Ownership of one transferable resource | The sender normally loses access after transfer. |
| SharedArrayBuffer | One backing block of bytes | All views observe writes to those bytes. |
| Normal variable | One agent's JavaScript value | It remains private to that agent. |
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 1 feature-detects the constructor and browser isolation. Lines 2 and 3 create shared bytes and a view. An isolated browser prints 5; another browser prints the requirement instead of throwing.
A shared whiteboard lets everyone read the same number. If two people change it together, they need a rule for who updates it safely.
- 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: Views read the same integer 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
Where the analogy stops: A whiteboard does not decide turn-taking. Your program still needs a protocol for contested changes.
Why ordinary updates can race
An increase looks like one source line, but its meaning is read, add, then write. Two workers can both read zero before either writes. Both calculate one, and the later write replaces the earlier one even though the team intended two increases.
This outcome is a lost update. It explains why a shared counter needs more than shared bytes. A real non-atomic run may choose a different schedule, so this lesson uses a deterministic model instead of waiting for a timing bug to appear.
1let count = 0;2const firstRead = count;3const secondRead = count;4count = firstRead + 1;5count = secondRead + 1;6console.log(count);Line 1 starts at zero. Lines 2 and 3 save zero for both simulated workers. Lines 4 and 5 both write one, so line 6 prints 1. The model chooses the dangerous both-read interleaving; it does not claim every race visibly loses an update.
Replay an instrumented teaching model of a lost update. It is not a claim that every non-atomic run loses an update.
script
2const firstRead = count;3const secondRead = count;4count = firstRead + 1;5count = secondRead + 1;6console.log(count);Step through the replay and stop at each saved read and write. It is a replay of instrumented lesson code, not an engine debugger. Its value is making the stale second observation visible before it overwrites the first result.
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));This model ends at 1. Both workers used the same earlier read, so the second write overwrote the first.
Choose another ordering. If a worker finishes before the other reads, the final count is two. If both read first, both use stale zero and the final count is one. Only the schedule changes, so the reason for the difference stays clear.
Atomic operations
Use an atomic operation when agents contend for one integer cell. It completes as one indivisible action with respect to other atomic operations. It does not make an entire application thread-safe; you still define state meanings and the order agents use them.
1const score = new Int32Array(new ArrayBuffer(4));2Atomics.store(score, 0, 3);3console.log(Atomics.load(score, 0));Line 1 creates one cell. Line 2 stores three. Line 3 atomically loads it and prints 3. Use load when the current value is the question.
1const score = new Int32Array(new ArrayBuffer(4));2console.log(Atomics.store(score, 0, 3));3console.log(Atomics.load(score, 0));Line 2 writes three and returns the stored value, so it prints 3. Line 3 confirms that value. Store changes state; it does not wake a waiting worker by itself.
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 starts at three. Line 3 adds two but returns the old value, so it prints 3. Line 4 loads after the change and prints 5. That old-value return is important for counters and compare-and-swap loops.
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 2 stores three and line 3 prints 3. The add and sub lines print old values 3 and 5. Exchange prints 4, compareExchange prints 9, and the final load prints 12.
| Operation | What it does | What it returns |
|---|---|---|
load | Read one cell atomically. | Returns the current value. |
store | Write one cell atomically. | Returns the stored value. |
add and sub | Change a cell by an amount. | Return the old value. |
exchange | Replace a cell unconditionally. | Returns the old value. |
compareExchange | Replace only when a value matches. | Returns the old value either way. |
These operations work on integer typed arrays, not arbitrary objects. Prefer messages for rich data and infrequent work. A shared cell is useful because its tiny contract is easy to document, inspect, and test.
Compare and swap
Compare-and-swap means “change it only if it still has the value I saw.” Atomics.compareExchange checks a cell and possibly replaces it in one atomic operation. It returns the old value whether the check succeeds or fails.
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));Line 2 stores three. Line 3 sees expected three, returns old value 3, and replaces the cell with eight. Line 4 prints 8. The comparison and replacement are one operation, not a separate if check followed by a later write.
Replay a real compare-and-swap call. The old value tells you whether the expected value matched.
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 success path: old value three and current cell eight have different jobs. The returned old value lets a caller tell whether it must refresh its observation and retry.
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 3 reads a score. Line 4 tries to replace it with one more. If another worker changed the cell first, the returned value differs from seen, line 5 refreshes the value, and the loop retries. With no competing write here, line 7 prints 4.
Keep retry loops small and give them a clear progress story. They suit compact state transitions, not unbounded work. The next memory-model lesson explains the ordering rules that make this pattern reliable.
Wait, notify, and waitAsync
A counter update is immediate. Sometimes an agent should pause until another agent changes a shared flag. Waiting lets an allowed worker sleep while a cell has an expected value instead of repeatedly reading in a busy loop.
1// worker.js: never call Atomics.wait on the browser main thread.2const cells = new Int32Array(sharedBuffer);3const result = Atomics.wait(cells, 0, 0);4console.log(result); // "ok" after another agent calls notify5 6// main thread or another worker7Atomics.store(cells, 0, 1);8Atomics.notify(cells, 0, 1);This is a worker sketch and is not runnable in the page sandbox. Line 2 makes a shared integer view. Line 3 blocks an allowed worker while cell zero is zero. Lines 7 and 8 store the new state first and then notify a waiter, allowing wait to return ok.
A waiter gets not-equal when the cell already changed before it waited. It can get timed-out when given a timeout. Notify is not a write; it is a wake-up signal after a store.
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 2 calls waitAsync, which returns an object rather than freezing the agent. When async is true, line 4 gets a promise and later prints ok. Otherwise line 7 immediately prints a status such as not-equal.
| Tool | Meaning | Where it belongs |
|---|---|---|
Atomics.wait | Blocks while a cell has an expected value. | Worker only in a browser. |
Atomics.notify | Wakes waiters for a cell. | Returns how many woke. |
Atomics.waitAsync | Returns a result whose value may be a promise. | Does not block the main thread. |
Think of this as a tiny state machine: zero means no job, one means job ready, and notify tells a sleeping worker to check again. Store state before notify. A wake-up is not proof that the state is still what the worker hoped for.
Cross-origin isolation
Browsers require cross-origin isolation before a page can create and send shared buffers. Shared memory and precise timers can support side-channel attacks such as Spectre, so the page and its embedded resources must opt into a stronger boundary.
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 1 sets Cross-Origin-Opener-Policy, or COOP, to same-origin. Line 2 sets Cross-Origin-Embedder-Policy, or COEP, to require-corp; credentialless is another COEP option. Line 4 checks crossOriginIsolated.
COEP changes how third-party resources load, so it is an application security decision rather than a local worker setting. Audit scripts, images, frames, and API resources before enabling it. The browser security model lesson goes deeper on COOP and COEP.
A worker counter in practice
A practical design stays small. A worker pool receives jobs by messages, and each completed job atomically increases one shared integer counter. The page can read the counter without asking every worker for another 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 1 reserves one progress cell. Lines 3 through 5 define the worker action: add one when a job finishes. Lines 7 and 8 model two workers completing jobs, and line 9 prints 2. A real worker receives the buffer in its startup message. This synchronous model can run in Node.js. A real browser worker needs a cross-origin-isolated page, and Node workers need the buffer passed through worker_threads; this editor sandbox has no shared buffer.
Reserve cells by purpose and document who writes them. Cell zero can be completed jobs, cell one total jobs, and cell two a ready flag. Store a state before notifying a worker, and let normal messages carry names, errors, and large results.
SharedArrayBufferAtomics.addAtomics.compareExchangeAtomics.waitAtomics.notifyCross-Origin-Embedder-Policy
Sort each card by whether it changes memory, coordinates agents, or configures browser access.
In Node, two worker_threads workers can each add one thousand on one SharedArrayBuffer counter and finish at exactly 2000. The lesson test proves that stable fact, compare-and-swap values, and a wait/notify wake-up without relying on an unreliable non-atomic race.
Common misconceptions
Shared memory is deliberately narrow. Most mistakes come from treating it as ordinary object sharing or treating one safe cell operation as a complete application design. Keep the layout small enough that another developer can explain each index.
- “SharedArrayBuffer shares all variables.” Only backing bytes are shared.
- “Atomics makes every algorithm thread-safe.” State layout and protocol still need design.
- “wait is await on the main thread.” wait blocks; waitAsync is non-blocking.
- “notify changes a value.” Store state first, then notify.
- “COOP and COEP are optional.” Browser shared memory needs isolation.
The safe default is still messages. Reach for shared memory after design and profiling point to a compact shared-state problem: a worker-pool counter, producer-ready flag, or WebAssembly memory bridge.
Practice exercises
Work from the visible cell value and protocol state. Each task asks about one operation or one browser rule, so trace the code before answering.
Read the program and type its output.
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: add changes zero and load reads the result.
Type the two values printed by this program.
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 then 4; the nonmatching cell stays unchanged.
Choose both workers reading first. What final count does the model show?
The both-read model ends at 1 because the second stale write overwrites the first.
Where may a browser app use blocking Atomics.wait?
Use blocking wait in a worker. The browser main thread needs waitAsync to stay responsive.
Which two headers enable cross-origin isolation?
Set COOP and COEP, then check crossOriginIsolated before using browser shared memory.
Which primitive updates a shared job counter?
Use Atomics.add for the completed-job counter, and use messages for job details.
Check your understanding
Separate shared bytes from private variables, changing a cell from waiting for a state change, and atomicity from a complete protocol.
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 return?
Choose an answer to see the explanation.
Question 4 of 8When does compare-and-swap write its replacement?
Choose an answer to see the explanation.
Question 5 of 8Why is blocking wait not for the browser main thread?
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 normal postMessage and later changes its local object. What does the receiver have?Choose an answer to see the explanation.
Question 8 of 8A waiter sees a cell is already 1 when it expected 0. What can waitAsync report immediately?
Choose an answer to see the explanation.
Key takeaways
- SharedArrayBuffer shares chosen bytes while variables and call stacks stay private.
- Use integer views and Atomics for contested cells.
- compareExchange changes only a value that still matches.
- wait and notify coordinate workers; waitAsync keeps a main thread responsive.
- Browser shared memory needs COOP, COEP, and crossOriginIsolated.
Remember the one-liner.
Shared memory shares bytes; Atomics gives contested integer cells a safe update rule.
Coming next: The memory model, where data races, ordering, and the guarantees behind atomic operations become precise.