JavaScript Lock-free Patterns: Concurrency Without Blocking
Lock-free patterns help you coordinate shared work without pausing threads with traditional locks. In JavaScript, this matters most when you use shared state across async tasks, Web Workers, or atomic operations on shared memory.
Quick answer: Lock-free code avoids mutex-style blocking by using atomic state changes, retry loops, and ownership rules instead of waiting for a lock. In JavaScript, you usually build these patterns with Atomics, careful state design, and message passing.
Difficulty: Advanced
You'll understand this better if you know: basic JavaScript functions, promises and async behavior, and the idea that two pieces of code can try to update the same value at the same time.
1. What Is Lock-free Patterns?
Lock-free patterns are techniques for safely coordinating concurrent work without using a traditional lock that blocks other code until it is released. Instead of saying, “only one worker may enter this section at a time,” lock-free code tries to make progress through atomic updates, retries, and ownership rules.
- They reduce waiting by avoiding blocking locks.
- They depend on atomic operations or careful single-writer design.
- They are common in systems programming, but the ideas also apply to JavaScript concurrency.
- They are not the same as “no shared state”; they are a way to manage shared state more efficiently.
In JavaScript, the term is most relevant when code runs in parallel across Web Workers or when shared memory is used with SharedArrayBuffer and Atomics.
2. Why Lock-free Patterns Matter
JavaScript often feels single-threaded, but modern apps can still face concurrency problems through interleaved async tasks, worker threads, and shared memory. When multiple execution contexts touch the same data, you can get race conditions, lost updates, stale reads, and hard-to-reproduce bugs.
Lock-free patterns matter because they can improve responsiveness and avoid deadlocks or long stalls. They are especially useful when:
- you need a fast shared counter or queue;
- you want one task to claim work without blocking others;
- you need predictable progress under contention;
- you are building coordination logic for workers, schedulers, or in-memory caches.
They are less useful when your problem is simple data flow between components. In many JavaScript apps, message passing is still easier and safer than shared mutable state.
3. Basic Syntax or Core Idea
JavaScript does not have a built-in lock keyword. The core idea is to make each state transition safe and observable so that competing tasks do not corrupt the data.
Atomic compare-and-swap style thinking
A common lock-free pattern is: read a value, compute a new value, and write it only if nobody changed it in the meantime. If the value changed, try again.
let done = false;
function tryClaim() {
if (done) return false;
done = true;
return true;
}This looks simple, but in real concurrency it is not safe by itself because another task could observe the same value before the write. In JavaScript worker code, you need atomic primitives for shared memory, or a design that prevents concurrent writers entirely.
Atomic operations with shared memory
When you use a typed array backed by shared memory, Atomics provides operations that are performed as indivisible steps.
const buffer = new SharedArrayBuffer(4);
const state = new Int32Array(buffer);
// Try to change 0 to 1 only if nobody changed it first.
const previous = Atomics.compareExchange(state, 0, 0, 1);If previous is 0, the claim succeeded. If not, some other code already changed the state.
4. Step-by-Step Examples
Example 1: A lock-free one-time claim
Suppose several tasks try to start the same job, but only one should win. This is a classic lock-free ownership problem.
const buffer = new SharedArrayBuffer(4);
const flag = new Int32Array(buffer);
function claimJob() {
const oldValue = Atomics.compareExchange(flag, 0, 0, 1);
return oldValue === 0;
}
if (claimJob()) {
// This caller won the race.
}This works because the state change from 0 to 1 happens atomically. Only one execution context can observe the transition as successful.
Example 2: A lock-free counter using atomic add
Counting events is another common use case. Instead of locking around a read-modify-write sequence, use an atomic increment.
const buffer = new SharedArrayBuffer(4);
const count = new Int32Array(buffer);
function recordEvent() {
return Atomics.add(count, 0, 1) + 1;
}Atomics.add returns the old value, so adding 1 gives the updated count. This is safer than manually reading, incrementing, and writing back.
Example 3: A simple spin-and-retry loop
Some lock-free algorithms retry when they lose the race. This is common in queues, caches, and state machines.
const buffer = new SharedArrayBuffer(4);
const state = new Int32Array(buffer);
function acquireState() {
while (true) {
const current = Atomics.load(state, 0);
if (current !== 0) return false;
const won = Atomics.compareExchange(state, 0, 0, 1);
if (won === 0) return true;
}
}The retry loop is the “lock-free” part: a failed attempt does not block other code, and the caller keeps trying until it either succeeds or gives up by policy.
Example 4: Message passing instead of shared mutation
One of the safest lock-free patterns in JavaScript is to avoid shared writable state altogether and move work through messages.
// main.js
const worker = new Worker("worker.js");
worker.postMessage({ type: "increment" });
// worker.js
let localCount = 0;
onmessage = (event) => {
if (event.data.type === "increment") {
localCount += 1;
}
};This is often easier to reason about than shared memory because each worker owns its own state and communicates by events.
5. Practical Use Cases
- Claiming a task exactly once across multiple workers.
- Incrementing a shared metric or progress counter without lost updates.
- Building a lock-free work queue or ring buffer for high-throughput worker communication.
- Implementing a state flag for initialization, shutdown, or cache invalidation.
- Coordinating producer and consumer workers with minimal blocking.
In browser apps, these patterns are usually found in advanced performance code, not everyday UI logic. If you are not using shared memory or workers, you may not need them at all.
6. Common Mistakes
Mistake 1: Using normal read-modify-write on shared state
Beginners often read a value, update it, and write it back, assuming the sequence is safe. In concurrent code, another task can change the value between those steps.
Problem: This can cause lost updates because both writers may read the same old value and overwrite each other.
let count = 0;
function increment() {
const current = count;
count = current + 1;
}Fix: Use an atomic operation on shared memory, or move the counter into a single owner that serializes updates.
const buffer = new SharedArrayBuffer(4);
const count = new Int32Array(buffer);
function increment() {
return Atomics.add(count, 0, 1) + 1;
}The corrected version works because the update is performed atomically instead of in separate steps.
Mistake 2: Assuming lock-free means “no waiting”
Lock-free algorithms can still retry. Under contention, a task may loop many times before it succeeds.
Problem: If you write a busy loop with no escape path, the main thread can become unresponsive even though the algorithm is technically lock-free.
function waitForever(flag) {
while (Atomics.load(flag, 0) === 0) {
// Busy spin
}
}Fix: Use a bounded retry strategy, backoff, or a blocking wait only in worker code where that behavior is appropriate.
function waitWithLimit(flag, limit = 1000) {
let attempts = 0;
while (Atomics.load(flag, 0) === 0 && attempts < limit) {
attempts += 1;
}
return Atomics.load(flag, 0) === 1;
}The fixed version avoids an infinite spin and gives the caller a clear failure path.
Mistake 3: Using non-shared data and expecting cross-worker coordination
A plain array or object is not shared between workers. Each worker gets its own copy unless you explicitly use shared memory or pass data through messages.
Problem: The code appears to update shared state, but each worker is actually changing its own private copy, so the result never matches the intended coordination.
const state = { ready: false };
// In a worker
state.ready = true;Fix: Use message passing for ownership transfer, or use shared memory plus Atomics when true sharing is required.
const buffer = new SharedArrayBuffer(4);
const ready = new Int32Array(buffer);
Atomics.store(ready, 0, 1);The corrected version works because all participating contexts observe the same shared memory location.
7. Best Practices
Prefer message passing when you do not truly need shared memory
Shared mutable state is the hardest part of concurrency. If a worker can own its own data and report results back, message passing is usually simpler and less error-prone.
// Safer ownership model
worker.postMessage({ task: "build-index" });This reduces the number of race conditions you must reason about.
Keep shared state tiny and clearly documented
Lock-free code becomes difficult when too many values are shared. A small shared flag or counter is easier to verify than a large shared object.
const buffer = new SharedArrayBuffer(8);
const shared = new Int32Array(buffer);
// shared[0] = job state, shared[1] = work countDocument what each slot means so another developer does not treat the array like a generic storage bucket.
Design for failure and retry limits
Under heavy contention, a lock-free attempt may fail repeatedly. Your algorithm should define when to retry, when to back off, and when to stop.
function claimWithRetries(flag, maxAttempts = 10) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (Atomics.compareExchange(flag, 0, 0, 1) === 0) return true;
}
return false;
}That makes performance predictable and prevents infinite retry loops.
8. Limitations and Edge Cases
- Lock-free does not automatically mean faster; under low contention, simpler code can perform better.
- JavaScript lock-free patterns are mainly practical with SharedArrayBuffer and Atomics; plain objects do not provide atomicity across workers.
- Busy spinning can waste CPU, especially on the main thread.
- Browser support for shared memory depends on cross-origin isolation requirements in many environments.
- Lock-free algorithms can still starve individual tasks if one contender keeps losing the race.
- Some operations cannot be made safely lock-free without changing the design, such as multi-step updates that must remain consistent as a group.
If you see a pattern that requires many coordinated writes, ask whether a single owner, a queue, or message passing would be simpler than shared-memory coordination.
9. Practical Mini Project
Here is a small worker-based counter that uses shared memory and atomic increments. It demonstrates a minimal, practical lock-free pattern: multiple workers update one shared counter safely.
// main.js
const buffer = new SharedArrayBuffer(4);
const counter = new Int32Array(buffer);
const workerA = new Worker("worker.js");
const workerB = new Worker("worker.js");
workerA.postMessage(buffer);
workerB.postMessage(buffer);
setTimeout(() => {
console.log("Final count:", Atomics.load(counter, 0));
}, 100);
// worker.js
onmessage = (event) => {
const counter = new Int32Array(event.data);
for (let i = 0; i < 1000; i++) {
Atomics.add(counter, 0, 1);
}
};This mini project shows a shared counter updated safely without a manual lock. Each worker performs atomic increments, and the main thread reads the final result later.
10. Key Points
- Lock-free patterns coordinate concurrent work without traditional blocking locks.
- In JavaScript, they are most relevant with workers, shared memory, and Atomics.
- Atomic updates prevent lost writes and inconsistent state.
- Lock-free does not mean wait-free; retries can still happen.
- Message passing is often simpler than shared mutable state.
11. Practice Exercise
Build a small shared-state claim system for two workers.
- Create a SharedArrayBuffer with one integer slot.
- Use Atomics.compareExchange so only one worker can change the slot from 0 to 1.
- Have each worker report whether it won the claim.
- Print the final shared value in the main thread.
Expected output: One worker reports success, one reports failure, and the final value is 1.
Hint: The winner is the worker whose compare-exchange sees the old value 0.
Solution:
// main.js
const buffer = new SharedArrayBuffer(4);
const slot = new Int32Array(buffer);
const worker1 = new Worker("worker.js");
const worker2 = new Worker("worker.js");
worker1.postMessage(buffer);
worker2.postMessage(buffer);
worker1.addEventListener("message", (event) => console.log("Worker 1:", event.data));
worker2.addEventListener("message", (event) => console.log("Worker 2:", event.data));
setTimeout(() => {
console.log("Final value:", Atomics.load(slot, 0));
}, 100);
// worker.js
onmessage = (event) => {
const slot = new Int32Array(event.data);
const prev = Atomics.compareExchange(slot, 0, 0, 1);
self.postMessage(prev === 0 ? "won" : "lost");
};This solution works because the claim is performed with one atomic state transition, so only one worker can win.
12. Final Summary
Lock-free patterns let JavaScript coordinate concurrent work without relying on blocking locks. They are most useful when several execution contexts must safely share a small piece of state, such as a counter, a claim flag, or a work slot.
In practice, the most important tools are atomic operations, retry logic, and clear ownership boundaries. If you can avoid shared memory altogether, message passing is often the better choice. If you do need shared state, keep it small, make updates atomic, and design for contention from the beginning.
As a next step, compare lock-free coordination with message-passing designs in worker-based apps, then practice one shared counter and one one-time claim implementation.