➔ Back to Labs Blog Security

Preventing State Corruption with Atomic Mutex Locks in Node.js

How to build safe filesystem-based lock primitives to run concurrent background agents without race conditions.

📅 2026-08-17 👤 By Steve Oatman 🏷️ STEVE.WEB Labs

The Concurrency Threat in Automated Workspaces

When building custom, autonomous software agents that write and compile files on the same repository concurrently, race conditions are inevitable. If a daily compliance audit script modifies your codebase while a deployment is running, or if two webhook requests edit the same file simultaneously, state corruption occurs.

To solve this, we engineer a filesystem-based atomic mutex lock primitive.

The Atomic Mutex Pattern

In Node.js and Bun, we can achieve true atomic file locking using native file system write options. Specifically, passing the exclusive write flag wx to fs.promises.writeFile guarantees that the write operation will fail if the file already exists.

Here is the exact TypeScript implementation of our atomic mutex lock:

import { promises as fs } from 'fs';
import * as path from 'path';
export class FileMutex {
  private lockPath: string;
  private holderPid: string;
  constructor(lockName: string = 'agent.lock') {
    this.lockPath = path.join(process.cwd(), lockName);
    this.holderPid = process.pid.toString();
  }
  /
  • Attempts to acquire the atomic filesystem lock.
  • Returns true on success, false on collision.
  • */ async acquire(): Promise { try { // 'wx' flag opens the file for writing but FAILS if the file exists. await fs.writeFile(this.lockPath, Locked by PID ${this.holderPid} at ${new Date().toISOString()}, { flag: 'wx' }); return true; } catch (err: any) { if (err.code === 'EEXIST') { // Lock is currently held by another process return false; } throw err; } } /
  • Forces the release of the lock.
  • */ async release(): Promise { try { await fs.unlink(this.lockPath); } catch (err: any) { if (err.code !== 'ENOENT') { throw err; // Ignore if the file was already deleted } } } /
  • Checks for stale locks (locks older than 15 minutes) and clears them automatically.
  • */ async reapStaleLock(maxAgeMs: number = 15 60 1000): Promise { try { const stats = await fs.stat(this.lockPath); const ageMs = Date.now() - stats.mtimeMs; if (ageMs > maxAgeMs) { await this.release(); console.warn(⚠️ Reaped stale lock file (age: ${Math.round(ageMs / 60000)} minutes).); return true; } return false; } catch { return false; // No lock file exists } } }

    Self-Healing and Stale Lock Recovery

    To make this mutex completely self-healing, the runner first executes reapStaleLock() before trying to acquire. If the lock was created by an execution process that crashed or hung, the background loop automatically unlinks the file and logs a warning to Slack.

    By wrapping our background agents and webhook handlers in this FileMutex, we guarantee that only one writer touches our files at any given second, ensuring perfect database and repository integrity.