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> = [];
#lockedtracks whether the mutex is currently held#acquirersstores 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
toolMutexinstance defined insrc/main.ts. - The Mutex implementation in
src/Mutex.tsprovides 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()andguard.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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →