Skip to content

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 c

Implementing 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

  • Lock serializes all callers — every call eventually runs in order
  • nonConcurrent collapses rapid calls — at most one queued call survives, preventing redundant work
  • Both use promise chaining internally, no setTimeout or polling

Trade-offs

ApproachProsCons
LockEvery call runs, orderedQueue can grow
nonConcurrentDrops redundant callsCallers may be skipped
Mutex librariesBattle-testedExternal dependency
Manual flagsNo abstraction neededError-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).