Appearance
Non-Concurrent Operations
Force sequential execution of async operations to prevent concurrent access to shared resources. Two patterns: a Lock class for scoped mutual exclusion, and a nonConcurrent wrapper that serializes function calls.
When to Use
- Protecting shared resources (database connections, files) from concurrent access
- Ensuring only one instance of an async operation runs at a time (e.g., save, sync, deploy)
- Queuing overlapping requests so they execute in order
- Debounce-like behavior where the latest call runs after the current one finishes
The Patterns
Lock class
Provides lock(fn) for scoped mutual exclusion and ready() to wait until the lock is free:
javascript
const dbLock = new Lock();
async function saveRecord(record) {
await dbLock.lock(async () => {
await db.write(record);
});
}
// Concurrent calls are serialized automatically
await Promise.all([
saveRecord(a),
saveRecord(b),
saveRecord(c),
]);
// Executes: a, then b, then cImplementing Lock
Queues callers with promises and releases them one at a time after each critical section completes:
javascript
class Lock {
#locked = false;
#waiting = [];
async lock(fn) {
await this.ready();
this.#locked = true;
try {
await fn();
} finally {
this.#unlock();
}
}
#unlock() {
this.#locked = false;
while (this.#waiting.length > 0) {
const fn = this.#waiting.shift();
fn();
}
}
async ready() {
if (!this.#locked) {
return Promise.resolve();
} else {
return new Promise((resolve) => {
this.#waiting.push(resolve);
});
}
}
}nonConcurrent wrapper
Wraps an async function so overlapping calls are queued. At most one execution runs at a time, plus one queued call waiting to run next:
javascript
const save = nonConcurrent(async () => {
await writeToDatabase(currentState);
});
// Rapid calls — only first runs immediately,
// one queued call runs after it finishes,
// extras are dropped
save(); // runs
save(); // queued
save(); // dropped (already queued)Implementing nonConcurrent()
Wraps an async function so at most one call runs at a time, with one additional call queued and duplicates dropped:
javascript
function nonConcurrent(asyncFunction) {
if (!(asyncFunction instanceof Function)) {
throw new TypeError(
"asyncFunction must be a function"
);
}
let queuedCall = false;
let readyPromise = null;
async function runAsyncFunction() {
readyPromise = new Promise(
async function (resolve, reject) {
try {
await asyncFunction();
resolve();
} catch (error) {
reject(error);
}
}
).finally(function () {
readyPromise = null;
});
}
async function nonConcurrentCall() {
const isRunning = Boolean(readyPromise);
if (!isRunning) {
runAsyncFunction();
return;
}
if (!queuedCall) {
queuedCall = true;
await readyPromise;
queuedCall = false;
runAsyncFunction();
return;
}
}
nonConcurrentCall.ready = async function ready() {
await readyPromise;
};
return nonConcurrentCall;
}Key details
Lockserializes all callers — every call eventually runs in ordernonConcurrentcollapses rapid calls — at most one queued call survives, preventing redundant work- Both use promise chaining internally, no
setTimeoutor polling
Trade-offs
| Approach | Pros | Cons |
|---|---|---|
Lock | Every call runs, ordered | Queue can grow |
nonConcurrent | Drops redundant calls | Callers may be skipped |
| Mutex libraries | Battle-tested | External dependency |
| Manual flags | No abstraction needed | Error-prone cleanup |
Choose Lock when every operation must execute (e.g., database writes that must all complete). Choose nonConcurrent when only the latest state matters and redundant calls can be safely dropped (e.g., UI saves, sync operations).