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.

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:

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

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 count

Document 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

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

11. Practice Exercise

Build a small shared-state claim system for two workers.

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.