Asher Cohen
Back to posts

Serial Execution in JavaScript: Sequential, Non-Overlapping Operations

Understanding serial execution — happens-before relationships, atomicity, and coordination patterns for async operations

Introduction

Serial execution means operations run sequentially without overlapping — each operation completes before the next begins. In JavaScript's async world, achieving true serial execution requires deliberate coordination using promises, async/await, atomics, or locking mechanisms.

What Is Serial Execution?

Definition: Sequential, non-overlapping execution on an object. Calls to methods on an object are serial if and only if there is a happens-before relationship between those calls, implying the calls do not overlap.

When calls are asynchronous, coordination to establish the happens-before relationship must be implemented using techniques such as atomics, monitors, or locks.

Why Serial Execution Matters

// ❌ Race condition — overlapping async operations
let balance = 100;

async function withdraw(amount) {
  const current = balance;        // Read
  await someAsyncOperation();     // Gap — another operation could interleave!
  balance = current - amount;     // Write (based on stale read)
}

// Two concurrent withdrawals could both read 100
withdraw(50); // Reads 100, will write 50
withdraw(30); // Also reads 100, will write 70 — overwrites first withdrawal!

// ✅ Serial execution — no overlap
async function withdrawSafe(amount) {
  const current = balance;
  await someAsyncOperation();
  balance = current - amount;
}

// Ensure serial execution
await withdrawSafe(50); // Completes fully
await withdrawSafe(30); // Only starts after first completes

Patterns for Serial Execution

Async/Await (Simplest)

// Natural serial execution with await
async function processItems(items) {
  for (const item of items) {
    await processItem(item); // Each completes before next starts
  }
}

// ❌ Not serial — all run concurrently
async function processItemsParallel(items) {
  items.forEach(async (item) => {
    await processItem(item); // All start immediately!
  });
}

// ✅ Serial with reduce
async function processItemsSerial(items) {
  await items.reduce(async (prev, item) => {
    await prev; // Wait for previous to complete
    return processItem(item);
  }, Promise.resolve());
}

Queue Pattern

class SerialQueue {
  constructor() {
    this.queue = [];
    this.processing = false;
  }
  
  async enqueue(fn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      this.process();
    });
  }
  
  async process() {
    if (this.processing) return;
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { fn, resolve, reject } = this.queue.shift();
      try {
        const result = await fn();
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
    
    this.processing = false;
  }
}

// Usage
const queue = new SerialQueue();

queue.enqueue(() => fetchUser(1));
queue.enqueue(() => fetchUser(2));
queue.enqueue(() => fetchUser(3));
// Each fetch completes before the next starts

Mutex/Lock Pattern

class Mutex {
  constructor() {
    this.locked = false;
    this.waitQueue = [];
  }
  
  async acquire() {
    if (!this.locked) {
      this.locked = true;
      return;
    }
    
    return new Promise(resolve => {
      this.waitQueue.push(resolve);
    });
  }
  
  release() {
    if (this.waitQueue.length > 0) {
      const next = this.waitQueue.shift();
      next(); // Resolve the next waiter
    } else {
      this.locked = false;
    }
  }
  
  async runExclusive(fn) {
    await this.acquire();
    try {
      return await fn();
    } finally {
      this.release();
    }
  }
}

// Usage
const mutex = new Mutex();

async function safeWithdraw(amount) {
  return mutex.runExclusive(async () => {
    const current = balance;
    await someAsyncOperation();
    balance = current - amount;
    return balance;
  });
}

Atomics (for SharedArrayBuffer)

// Low-level atomic operations for true parallelism (Web Workers)
const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);

// Atomic add — guaranteed serial even across threads
Atomics.add(view, 0, 1);

// Atomic compare-and-swap
Atomics.compareExchange(view, 0, expectedValue, newValue);

// Wait and notify for coordination
Atomics.wait(view, 0, expectedValue);
Atomics.notify(view, 0, 1);

Happens-Before Relationship

The happens-before relationship guarantees ordering:

// Operation A happens-before Operation B if:

// 1. A is sequenced before B in the same function
const a = computeA(); // A
const b = computeB(); // B — A happens-before B

// 2. A resolves a promise that B awaits
const result = await promiseA; // A completes
doSomething(result);            // B — A happens-before B

// 3. A is in a microtask queued before B's microtask
Promise.resolve().then(() => doA()); // A
Promise.resolve().then(() => doB()); // B — A happens-before B

Common Pitfalls

1. Assuming forEach is Serial

// ❌ forEach doesn't await
items.forEach(async (item) => {
  await processItem(item); // All start concurrently!
});

// ✅ for...of with await
for (const item of items) {
  await processItem(item); // Serial execution
}

2. Promise.all When Order Matters

// ❌ Concurrent — no happens-before
const [a, b] = await Promise.all([fetchA(), fetchB()]);

// ✅ Serial — clear happens-before
const a = await fetchA();
const b = await fetchB();

3. Missing Error Isolation

// ❌ One failure stops all processing
for (const item of items) {
  await processItem(item); // If one throws, rest are skipped
}

// ✅ Continue on error
for (const item of items) {
  try {
    await processItem(item);
  } catch (error) {
    console.error(`Failed to process ${item}:`, error);
  }
}

Best Practices

Do's

// ✅ Use for...of with await for serial execution
for (const item of items) {
  await processItem(item);
}

// ✅ Use queues for ordered async processing
await queue.enqueue(() => operation());

// ✅ Use mutex for critical sections
await mutex.runExclusive(() => criticalOperation());

// ✅ Handle errors per-item in serial loops
try { await process(item); } catch (e) { /* handle */ }

Don'ts

// ❌ Don't use forEach with async
items.forEach(async (item) => await process(item));

// ❌ Don't use Promise.all when order matters
await Promise.all(orderedOperations);

// ❌ Don't assume single-threaded means no races
// Async interleaving creates race conditions

// ❌ Don't forget error handling in serial chains
await op1(); // If this throws, op2 never runs
await op2();

Summary Table

PatternUse CaseMechanism
for...of + awaitSimple iterationAwait each iteration
Serial QueueOrdered task processingInternal queue + processing loop
Mutex/LockCritical sectionsAcquire/release pattern
AtomicsCross-thread coordinationHardware-level atomic ops
Reduce chainFunctional serial processingawait prev in reducer

Conclusion

Serial execution is about establishing clear happens-before relationships:

  • Use await in loops for simple serial processing
  • Implement queues for ordered async task execution
  • Use mutexes for critical sections that must not overlap
  • Understand happens-before to reason about execution order

In JavaScript's async world, serial execution doesn't happen automatically — you must design for it.

#javascript #async #concurrency #patterns #nodejs #web-development