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
| Pattern | Use Case | Mechanism |
|---|---|---|
for...of + await | Simple iteration | Await each iteration |
| Serial Queue | Ordered task processing | Internal queue + processing loop |
| Mutex/Lock | Critical sections | Acquire/release pattern |
| Atomics | Cross-thread coordination | Hardware-level atomic ops |
| Reduce chain | Functional serial processing | await prev in reducer |
Conclusion
Serial execution is about establishing clear happens-before relationships:
- Use
awaitin 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