Skip to content

Reference Counting Lock

When to use

Use a reference-counting lock when multiple independent callers need to acquire and release a shared lock, and the lock should only fully release when every caller has released it. This is different from a mutex — a mutex allows only one holder at a time. A reference-counting lock allows many holders to lock simultaneously and stays locked until all of them unlock. Common scenarios include multiple modal dialogs sharing a scroll lock on the body, several components each wanting the background inert, or multiple animations disabling pointer events on a container.

The pattern

Store lock state and a counter in a WeakMap keyed by the locked resource. On lock(), increment the counter. On unlock(), decrement it. When the counter reaches zero, perform the actual cleanup.

javascript
const locks = new WeakMap();

function lock(resource, onFirstLock) {
  if (locks.has(resource)) {
    locks.get(resource).count++;
  } else {
    const state = { count: 1 };
    locks.set(resource, state);
    onFirstLock(resource, state);
  }
}

function unlock(resource, onLastUnlock) {
  if (!locks.has(resource)) return;
  
  const state = locks.get(resource);
  state.count--;
  
  if (state.count === 0) {
    locks.delete(resource);
    onLastUnlock(resource, state);
  }
}

The WeakMap ensures that if the resource (typically a DOM element) is garbage-collected, the lock state is cleaned up automatically.

Why onFirstLock / onLastUnlock callbacks

The lock itself is a pure counter. Side effects — adding a CSS class, setting an attribute, saving scroll position — happen only in the callbacks. This separates the counting logic from what the lock controls, making the pattern reusable across different use cases.

Using reference-counted scroll lock

Multiple overlays can safely stack scroll locks. Each scrollLock() call increments an internal counter and returns an unlock function. The page stays fixed until the last lock is released:

javascript
const el = document.scrollingElement;

// Dialog A opens — lockCount = 1, element fixed
const unlockA = scrollLock(el);

// Dialog B opens on top — lockCount = 2, no style change
const unlockB = scrollLock(el);

// Dialog B closes — lockCount = 1, still fixed
unlockB();

// Dialog A closes — lockCount = 0, styles removed,
// scroll position restored
unlockA();

Implementing scrollLock()

scrollLock() fixes the scrolling element in place at its current scroll offset using position: fixed, then stores the original position and a lock count in a WeakMap. On unlock, it decrements the counter and only restores styles and scroll position when the counter reaches zero:

javascript
const lockedElements = new WeakMap();

function scrollLock(element = document.scrollingElement) {
  if (lockedElements.has(element)) {
    lockedElements.get(element).lockCount++;
  } else {
    const { scrollTop, scrollLeft } = element;
    
    // Fix element in place at current scroll offset
    Object.assign(element.style, {
      overflow: "hidden",
      position: "fixed",
      top: `-${scrollTop}px`,
      left: `-${scrollLeft}px`,
      width: "100%",
    });
    
    lockedElements.set(element, {
      scrollTop,
      scrollLeft,
      lockCount: 1,
    });
  }
  
  return function unlock() {
    scrollUnlock(element);
  };
}

function scrollUnlock(element = document.scrollingElement) {
  if (!lockedElements.has(element)) return;
  
  const data = lockedElements.get(element);
  data.lockCount--;
  
  if (data.lockCount === 0) {
    const { scrollTop, scrollLeft } = data;
    
    // Remove fixed positioning
    Object.assign(element.style, {
      overflow: "",
      position: "",
      top: "",
      left: "",
      width: "",
    });
    
    // Restore scroll position
    element.scrollTop = scrollTop;
    element.scrollLeft = scrollLeft;
    lockedElements.delete(element);
  }
}

Inert attribute with reference counting

inertLock() and inertUnlock() use the inert attribute's value as a reference counter. The browser treats any truthy value as inert, so the number has no effect on behavior but tracks how many layers want the element inert:

javascript
function inertLock(element) {
  const count =
    Number(element.getAttribute("inert") ?? 0) + 1;
  element.setAttribute("inert", count);
}

function inertUnlock(element) {
  const count =
    Number(element.getAttribute("inert") ?? 0) - 1;
  if (count > 0) {
    element.setAttribute("inert", count);
  } else {
    element.removeAttribute("inert");
  }
}

Using RefCountLock

RefCountLock wraps the reference counting pattern in a class with acquire() and release() methods. The onFirst callback runs only when the first lock is acquired, and onLast runs only when all locks are released:

javascript
const lock = new RefCountLock();
const body = document.scrollingElement;

function openModal() {
  lock.acquire(body, (el) => {
    el.style.overflow = "hidden";
  });
}

function closeModal() {
  lock.release(body, (el) => {
    el.style.overflow = "";
  });
}

Implementing RefCountLock

The class stores lock counts in a private WeakMap keyed by resource. acquire() increments the counter and calls onFirst for the initial lock. release() decrements the counter and calls onLast when it reaches zero:

javascript
class RefCountLock {
  #locks = new WeakMap();
  
  acquire(resource, onFirst) {
    if (this.#locks.has(resource)) {
      this.#locks.get(resource).count++;
    } else {
      this.#locks.set(resource, { count: 1 });
      onFirst?.(resource);
    }
  }
  
  release(resource, onLast) {
    if (!this.#locks.has(resource)) return;
    
    const state = this.#locks.get(resource);
    state.count--;
    
    if (state.count === 0) {
      this.#locks.delete(resource);
      onLast?.(resource);
    }
  }
  
  isLocked(resource) {
    return this.#locks.has(resource);
  }
}

Trade-offs

  • WeakMap vs Map. WeakMap lets garbage collection clean up stale entries when the resource is removed from the DOM. Use Map only if you need to iterate over all locked resources (but then you must handle cleanup manually).
  • No deadlock detection. The pattern trusts callers to eventually unlock. If a caller leaks a lock, the resource stays locked. Use lifecycle hooks or try/finally to guarantee cleanup.
  • Thread safety. JavaScript is single-threaded, so there are no race conditions on the counter. This pattern does not apply to multi-threaded environments without additional synchronization.
  • Returned unlock functions. Returning an unlock closure from lock() makes cleanup easy — the caller does not need to remember which resource to pass. But if the closure is lost (e.g. the calling function throws before storing it), the lock leaks.
  • Counter vs set of owners. A simple counter cannot tell you who holds the lock. If you need to debug stale locks, store a Set of owner identifiers instead of a count.