How Chrome DevTools MCP Handles Concurrent Tool Calls Using Mutex

Chrome DevTools MCP serializes concurrent tool invocations using a global FIFO mutex to prevent race conditions when accessing shared browser state.

The ChromeDevTools/chrome-devtools-mcp repository implements a strict concurrency control mechanism that ensures only one MCP tool manipulates the DevTools protocol or browser state at any given time. By wrapping every tool handler with an asynchronous mutex, the server prevents data races while maintaining deterministic execution order.

Why Concurrent Tool Calls Need Serialization in MCP

MCP (Model Context Protocol) servers can receive multiple simultaneous requests from different clients or parallel tool invocations. When these requests target the same Puppeteer Browser instance or DevTools session, parallel execution creates race conditions—for example, one call might navigate the page while another attempts to capture a screenshot, resulting in inconsistent or failed operations.

The Chrome DevTools MCP solves this by treating the entire tool execution pipeline as a critical section that requires exclusive access.

The Global toolMutex Pattern in src/main.ts

In src/main.ts, the server instantiates a single global mutex at module initialization:

const toolMutex = new Mutex();

Every registered tool handler wraps its execution logic with this mutex using a consistent pattern:

const guard = await toolMutex.acquire();
try {
  // ... tool execution logic ...
  return await performToolOperation(params);
} finally {
  guard.dispose();
}

This implementation guarantees FIFO (First-In-First-Out) ordering. Requests are processed strictly in the order they arrived, preventing starvation and ensuring predictable behavior under load.

Inside the Mutex Implementation (src/Mutex.ts)

The mutex implementation in src/Mutex.ts provides a lightweight, zero-dependency asynchronous locking primitive.

FIFO Queue and Fair Ordering

The mutex maintains two private fields:

  • #locked: Boolean indicating if the mutex is currently held
  • #acquirers: Array of resolver functions representing queued requests

When acquire() is called on a locked mutex, it creates a new Promise and pushes the resolver onto #acquirers. The FIFO queue ensures that when release() is called, it resolves the oldest waiting request first, maintaining fair ordering.

The Guard Class and RAII Pattern

The Guard class implements the RAII (Resource Acquisition Is Initialization) pattern:

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

This design ensures that the lock is always released when the guard is disposed, even if the tool handler throws an exception. The finally block in src/main.ts guarantees cleanup without requiring explicit error handling in every tool implementation.

Protecting DevTools Universe Creation

Beyond tool execution, the mutex pattern appears in src/DevtoolsUtils.ts within the UniverseManager class. This manager handles the creation and deletion of DevTools "universes"—isolated DevTools protocol sessions tied to specific Puppeteer pages.

Using a dedicated mutex for universe lifecycle operations prevents:

  • Duplicate universe creation for the same page
  • Race conditions between universe initialization and cleanup
  • Stale references when pages close while tools are executing

Practical Usage Examples

Basic Tool Handler Pattern

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

const mutex = new Mutex();

async function safeToolCall(params: any) {
  const guard = await mutex.acquire();
  try {
    console.log('Executing tool with exclusive access');
    await performOperation(params);
    return { success: true };
  } finally {
    guard.dispose();
    console.log('Lock released');
  }
}

Concurrent Request Simulation

const toolMutex = new Mutex();

async function concurrentTask(id: number) {
  const guard = await toolMutex.acquire();
  try {
    console.log(`Task ${id} started`);
    await new Promise(resolve => setTimeout(resolve, 100));
    console.log(`Task ${id} completed`);
  } finally {
    guard.dispose();
  }
}

// Launch three overlapping tasks
Promise.all([
  concurrentTask(1),
  concurrentTask(2),
  concurrentTask(3)
]);

Output will always show sequential execution: Task 1 starts and completes, then Task 2, then Task 3, demonstrating the FIFO guarantee.

Summary

  • Chrome DevTools MCP uses a global toolMutex instance in src/main.ts to serialize all tool invocations
  • The FIFO mutex implementation in src/Mutex.ts guarantees fair ordering and prevents starvation using a queue of resolver functions
  • RAII-style Guard objects ensure locks are always released via dispose(), even when exceptions occur
  • The same pattern protects DevTools universe lifecycle operations in src/DevtoolsUtils.ts to prevent race conditions during creation and cleanup
  • This architecture allows the Node.js event loop to remain responsive while ensuring exclusive access to shared browser state

Frequently Asked Questions

What happens if a tool call throws an exception while holding the mutex?

The finally block in src/main.ts guarantees that guard.dispose() runs regardless of whether the tool handler succeeds or throws. This releases the mutex immediately, preventing deadlocks and allowing the next queued request to proceed.

Why does Chrome DevTools MCP use a FIFO mutex instead of a simple boolean lock?

A simple boolean lock could lead to starvation or unpredictable ordering under high concurrency. The FIFO queue in src/Mutex.ts ensures that tool requests are processed in the exact order they arrived, providing deterministic behavior that matches client expectations and simplifies debugging.

Can multiple different tools run concurrently if they target different browser pages?

No. The current implementation uses a single global mutex (toolMutex in src/main.ts) that serializes all tool executions regardless of their target page or tool type. While this is conservative, it prevents any potential race conditions in shared DevTools protocol state or Puppeteer internals. Future versions could implement finer-grained locking per page or domain if profiling reveals contention bottlenecks.

How does the Mutex implementation differ from standard JavaScript Promise patterns?

Unlike basic Promise-based locks that might use Promise.resolve() chains, the Mutex class in src/Mutex.ts maintains an explicit array of resolver functions (#acquirers). This design choice enables true FIFO ordering and provides the Guard abstraction for automatic cleanup, distinguishing it from simpler semaphore implementations or the async-mutex library patterns.

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 →