How chrome-devtools-mcp Handles Concurrent Tool Requests: The Mutex Implementation in src/Mutex.ts

Chrome DevTools MCP serializes concurrent tool requests using a lightweight FIFO async Mutex implemented in src/Mutex.ts, ensuring that only one tool manipulates shared DevTools state at a time while maintaining fair, deterministic request ordering.

The chrome-devtools-mcp repository provides a Model Context Protocol (MCP) server that exposes Chrome DevTools functionality to AI agents. Because multiple clients can invoke tools like performance, emulation, or network simultaneously, the server must prevent race conditions when accessing shared Puppeteer Browser instances or DevTools protocol sessions.

Why Concurrent Tool Requests Require Serialization

When MCP clients send simultaneous tool requests, parallel execution risks corrupting shared state. Two concurrent calls modifying browser emulation settings could interleave operations, leaving the Browser instance in an inconsistent state. To eliminate this risk, chrome-devtools-mcp implements a global serialization mechanism that forces all tool handlers to execute sequentially in the exact order they were received.

Global Tool Serialization with toolMutex in src/main.ts

The entry point src/main.ts instantiates a single global mutex and wraps every registered tool handler with exclusive acquisition logic.

At the top of the file, the server creates the mutex instance:

const toolMutex = new Mutex();

When registering tool handlers, the code wraps the execution flow with an acquisition pattern:

const guard = await toolMutex.acquire();
try {
  // ... tool logic that accesses shared DevTools state ...
  return await executeToolLogic(params);
} finally {
  guard.dispose();
}

This pattern guarantees that only one tool request holds the lock at any moment. The acquire() method returns a Guard object that must be disposed to release the lock, ensuring cleanup occurs even if the tool throws an exception.

Internal Universe Management with Mutex in src/DevtoolsUtils.ts

Beyond tool handlers, the UniverseManager class in src/DevtoolsUtils.ts uses a dedicated Mutex to synchronize the creation and deletion of DevTools "universes"—isolated DevTools sessions bound to Puppeteer Page instances.

The mutex ensures that:

  • Universe initialization never overlaps with cleanup
  • No duplicate universes are created for the same page
  • Stale references are safely removed without race conditions

This internal usage demonstrates that the Mutex primitive is reusable across different subsystems requiring deterministic, sequential access to shared asynchronous resources.

The FIFO Async Mutex Implementation in src/Mutex.ts

The src/Mutex.ts file implements a lightweight, zero-dependency FIFO (first-in-first-out) async mutex in pure TypeScript. The design prioritizes fairness: requests are served in the exact order they were received, preventing starvation.

The Guard Class

The Guard class acts as a RAII (Resource Acquisition Is Initialization) handle for the lock:

export class Guard {
  #mutex: Mutex;
  
  constructor(mutex: Mutex) {
    this.#mutex = mutex;
  }
  
  dispose(): void {
    this.#mutex.release();
  }
}

When dispose() is called, it invokes the parent mutex's release() method, ensuring the lock is freed even if the protected code throws an error.

Private State Fields

The mutex maintains two private fields:

#locked: boolean = false;
#acquirers: Array<(guard: Guard) => void> = [];
  • #locked tracks whether the mutex is currently held
  • #acquirers stores a queue of resolver functions for pending lock requests

The acquire() Method

The acquire() method implements the FIFO queuing logic:

async acquire(): Promise<Guard> {
  if (!this.#locked) {
    this.#locked = true;
    return new Guard(this);
  }
  
  return new Promise<Guard>((resolve) => {
    this.#acquirers.push(resolve);
  });
}

If the lock is free, it is taken immediately. Otherwise, the caller is suspended in a Promise that resolves when all prior queued requests have completed and released the lock.

The release() Method

The release() method handles handoff to the next waiter:

release(): void {
  const next = this.#acquirers.shift();
  if (next) {
    next(new Guard(this));
  } else {
    this.#locked = false;
  }
}

By shifting from the front of the #acquirers array, the mutex guarantees strict FIFO ordering. If the queue is empty, the mutex simply unlocks.

Practical Usage Examples

Serializing Tool Invocations

The primary use case in chrome-devtools-mcp wraps every tool handler to prevent concurrent access to the DevTools protocol:

import {Mutex} from './Mutex.js';

const toolMutex = new Mutex();

async function handlePerformanceTool(params) {
  const guard = await toolMutex.acquire();
  try {
    // Exclusive access to Chrome DevTools protocol
    const metrics = await page.metrics();
    return {result: metrics};
  } finally {
    guard.dispose();
  }
}

Manual Critical Section Protection

You can reuse the same primitive for any asynchronous critical section:

import {Mutex} from './src/Mutex.js';

const mutex = new Mutex();

async function criticalTask(id: number) {
  const guard = await mutex.acquire();
  try {
    console.log(`Task ${id} executing`);
    await someAsyncWork();
  } finally {
    guard.dispose();
    console.log(`Task ${id} completed`);
  }
}

// Launch several overlapping calls – they will run one after another
criticalTask(1);
criticalTask(2);
criticalTask(3);

Output will always show tasks completing in order 1 → 2 → 3, demonstrating the FIFO guarantee.

Summary

  • chrome-devtools-mcp handles concurrent tool requests by serializing execution through a global toolMutex instance defined in src/main.ts.
  • The Mutex implementation in src/Mutex.ts provides a lightweight, zero-dependency FIFO async lock using a private queue of Promise resolver functions.
  • Every tool handler wraps its execution in await toolMutex.acquire() and guard.dispose(), ensuring exclusive access to shared DevTools resources.
  • The same primitive protects UniverseManager operations in src/DevtoolsUtils.ts, preventing race conditions during DevTools session creation and cleanup.
  • The Guard-based RAII pattern ensures locks are released even when exceptions occur, preventing deadlocks.

Frequently Asked Questions

How does chrome-devtools-mcp prevent race conditions between simultaneous tool calls?

Chrome-devtools-mcp prevents race conditions by wrapping every tool handler with a global FIFO mutex (toolMutex) instantiated in src/main.ts. When a client invokes a tool, the handler must first await toolMutex.acquire(), which queues the request if another tool is currently running. This guarantees that only one tool accesses the shared Puppeteer Browser or DevTools protocol at a time, executing requests in the exact order they were received.

What makes the Mutex implementation in src/Mutex.ts different from other JavaScript locks?

The src/Mutex.ts implementation is specifically designed for fair FIFO ordering and zero dependencies. Unlike simple boolean locks or semaphore libraries that may resolve waiters in unpredictable order, this mutex maintains a private #acquirers queue (an array of Promise resolvers) and always shifts from the front in the release() method. This ensures strict first-in-first-out execution, preventing starvation and making tool behavior deterministic under high concurrency.

Can the Mutex be used outside of the chrome-devtools-mcp server for other async operations?

Yes, the Mutex class is a generic, reusable primitive that can protect any asynchronous critical section in Node.js or browser TypeScript code. Because it has no external dependencies and uses standard JavaScript private fields and Promises, you can import it into any project requiring serialized access to shared resources. The Guard pattern with dispose() makes it particularly safe for use with try/finally blocks, ensuring locks release even if the protected code throws exceptions.

How does the Guard pattern in the Mutex implementation prevent deadlocks?

The Guard class implements a RAII (Resource Acquisition Is Initialization) pattern where the lock holder receives a Guard instance from acquire(). When the dispose() method is called—typically in a finally block—it automatically invokes the parent mutex's release() method. This design ensures that the lock is always released when the guard goes out of scope or is explicitly disposed, even if the protected asynchronous code throws an error or returns early, effectively eliminating the risk of deadlocks from forgotten lock releases.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →