Appearance
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.
WeakMaplets garbage collection clean up stale entries when the resource is removed from the DOM. UseMaponly 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/finallyto 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
unlockclosure fromlock()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
Setof owner identifiers instead of a count.