Diagnosed and resolved a production bug where infinite loading screens and simultaneous overlay conflicts were degrading the user experience. Designed a reference-counted singleton with a withLoading(fn) wrapper that guarantees start/stop pairing via finally, then retrofitted it across async actions app-wide.
Run both tasks concurrently and watch how each panel handles the loading overlay.
Task A finishes at 1s and sets isLoading = false — the overlay disappears even though Task B is still running.
The overlay stays visible until both tasks complete — the counter only reaches 0 when all callers have called stop().
let loadingCount = 0;
function start() {
loadingCount = loadingCount + 1;
if (loadingCount === 1) setGlobalLoading(true);
}
function stop(forceStop = false) {
if (forceStop) loadingCount = 0;
if (loadingCount > 0) loadingCount = loadingCount - 1;
if (loadingCount === 0) setGlobalLoading(false);
}
async function withLoading(fn) {
start();
try {
return await fn();
} finally {
stop(); // always runs — even if fn() throws
}
}
export const loadingService = {
stop, // Note: We don't export start so that the service can't be started without a withLoading call.
withLoading,
get running() {
return loadingCount > 0;
},
}; The reference counter is the key insight: start() increments and stop() decrements, so the overlay only clears when every caller has finished — not just the first one that returns. Wrapping calls in withLoading() makes cleanup automatic: the finally block guarantees stop() runs even if the wrapped function throws an exception, preventing the overlay from getting permanently stuck.
// Before: each action manages its own loading flag
async function saveUser(data) {
isLoading = true; // flickers off between calls
await api.save(data);
isLoading = false;
}
// After: withLoading handles start/stop automatically
async function saveUser(data) {
await loadingService.withLoading(async () => {
await api.save(data);
});
}