Design Patterns

Monitor Object Design Pattern

I had a shared counter in a Node.js worker setup. Two async operations would read the counter, both see 5, both increment to 6. I lost an increment. Class...

23 Mar 2024

Monitor Object Design Pattern

I had a shared counter in a Node.js worker setup. Two async operations would read the counter, both see 5, both increment to 6. I lost an increment. Classic race condition.

The Monitor Object pattern wraps a shared resource with built-in synchronization. Only one caller can access the resource at a time. Everyone else waits.

Think of it like a single-occupancy bathroom. There's a lock on the door. If someone's inside, you wait. When they leave, the next person goes in.

Javascript
class Monitor {
  constructor() {
    this.counter = 0;
    this.lock = false;
  }

  async synchronized(func) {
    while (this.lock) {
      await new Promise(resolve => setTimeout(resolve, 10));
    }
    this.lock = true;
    try {
      await func();
    } finally {
      this.lock = false;
    }
  }

  async increment() {
    await this.synchronized(async () => {
      this.counter++;
      console.log("Counter incremented:", this.counter);
    });
  }

  async decrement() {
    await this.synchronized(async () => {
      this.counter--;
      console.log("Counter decremented:", this.counter);
    });
  }

  getCount() {
    return this.counter;
  }
}

const monitor = new Monitor();

async function run() {
  await monitor.increment();
  await monitor.decrement();
  await monitor.increment();
  await monitor.increment();
  console.log("Final count:", monitor.getCount());
}

run();

The synchronized method is the gatekeeper. It checks the lock, waits if it's taken, acquires it, runs the function, then releases. The try/finally guarantees the lock is released even if the function throws.

This implementation uses a simple spin-wait. In production, you'd use a proper async mutex (like async-mutex on npm) for better performance and fairness.

Important Caveat for JavaScript

JavaScript is single-threaded in the main event loop. True race conditions only happen with async operations — two awaits interleaving, not two threads running simultaneously. But the pattern is still relevant: any time two async flows can read-then-write the same state, you need synchronization.

In Node.js with worker threads, the problem is real multi-threading. There you'd use SharedArrayBuffer and Atomics, not this approach.

The benefit: Thread-safe access to shared state. Clean API — callers don't manage locks manually. The monitor encapsulates both the data and the synchronization logic.

The cost: Contention. If many callers compete for the lock, throughput drops. The spin-wait loop wastes CPU cycles. And in JavaScript's async model, it's easy to create subtle deadlocks if synchronized calls nest (a synchronized method calling another synchronized method on the same monitor).

I use this pattern when multiple async operations modify shared state and ordering matters. For simple cases, restructuring the code to avoid shared mutable state is better.

Keep reading