execution-blocker
A promise-based FIFO execution lock with independent queues per id — run async logic strictly one at a time. Zero dependencies.
Built for the async logic that must not overlap: refreshing an
auth token once instead of five times in parallel, serializing
writes to a file or IndexedDB, keeping "read, then update"
atomic, draining actions against one resource in order.
JavaScript won't interleave your statements — but every
await is a door for another caller to walk through.
An execution blocker closes it: callers of the same
queue line up and run strictly one after
another, in the order they arrived.
A hold taken with block() keeps its queue closed
until its release function is called — forget it on
one code path (an early return, a thrown error) and
every later caller of that queue waits forever. That is why
run() is the default choice: it
acquires, executes and releases in a finally, even
when the task throws. Reach for
block() only when acquire and
release genuinely live in different places — and pair it with
try/finally yourself.
Getting started
Install#
npm i @dmytromykhailiuk/execution-blocker
None. No runtime dependencies, no framework, no DOM — plain TypeScript that works in any browser and in Node ≥ 18. Ships ESM and CJS with type declarations for both.
Quick start#
Create a blocker once at module scope. Wrap the logic that must
not overlap in run(id, fn) — same-id tasks execute
strictly one after another, whoever called first goes first.
import { createExecutionBlocker } from "@dmytromykhailiuk/execution-blocker";
const blocker = createExecutionBlocker();
// Ten parallel calls — the body still runs strictly one at a time.
const refreshToken = () =>
blocker.run("auth", async () => {
if (!isExpired(token)) return token; // later callers see the fresh token
token = await api.refresh(); // executed once, not ten times
return token;
});
run() resolves with the task's result and rejects
with its error — and either way the queue moves on to the next
task. Different ids never wait for each other; see
Independent queues.
One id, one queue#
JavaScript is single-threaded, and that lulls: no two lines of
your function ever run at the same instant. But the moment a
function awaits, it yields — and any other caller is
free to enter the same function and interleave with it. Two
overlapping "check, then write" sequences and the classic race is
back:
// two components call this at the same time
async function refreshToken() {
if (!isExpired(token)) return token;
token = await api.refresh(); // ← both are already past the check:
return token; // the token is refreshed twice
}
The blocker's model is a set of named FIFO queues. A task joins the queue for its id, waits for everyone before it, runs, and hands the lock to the next in line:
-
Same id — strict order. Queue position is
taken synchronously at the
block()/run()call, so "who called first" is exactly "who runs first" — the event loop can't reorder waiters. -
Different ids — full independence. Queues
share nothing; a slow task on
"files"never delays"auth". -
Different blockers — isolated worlds. Each
createExecutionBlocker()call owns its queues outright. Two blockers using the same id never see each other.
Declare the blocker in one module and import it everywhere the shared resource is touched — a lock only protects the callers who go through it:
import { createExecutionBlocker } from "@dmytromykhailiuk/execution-blocker";
export const blocker = createExecutionBlocker();
// anywhere else in the app
import { blocker } from "./blocker";
await blocker.run("auth", refresh);
Reference
createExecutionBlocker#
function createExecutionBlocker(): ExecutionBlocker;
Takes nothing, returns a frozen blocker with empty queues. Queues come into being on first use of an id and are deleted when the last hold releases — there is no registration step and no cleanup step.
The blocker object#
Everything below is a stable reference on a frozen object. Every
method takes the queue id as its first argument; omit it to use
the shared "default" queue (exported as
DEFAULT_QUEUE).
Acquire the id queue, execute fn,
release — even when fn throws. Resolves with
fn's result. The safe default. See
run.
Whether anything currently holds or waits for the
id queue. See
isLocked & pending.
How many holds are active on the id queue —
the one currently running plus everyone waiting. See
isLocked & pending.
run#
run is block with the release handled
for you: it acquires the queue, executes the task, and releases
in a finally — no code path can leave the queue
closed. It resolves with whatever the task returns:
const user = await blocker.run("user:42", async () => {
const current = await db.read("user:42");
const next = { ...current, visits: current.visits + 1 };
await db.write("user:42", next); // no other "user:42" task runs in between
return next;
});
Tasks may be synchronous too — the result is still delivered as a promise, and the queue order still holds:
await blocker.run("stats", () => recompute()); // sync fn, same queue
await blocker.run(async () => flush()); // no id → "default" queue
Errors don't jam the queue#
A task that throws — synchronously or via a rejected promise —
does not poison anything. run() releases the lock,
rejects with the original error, and the next task in line starts
as usual:
await blocker
.run("q", async () => {
throw new Error("boom");
})
.catch((error) => log(error)); // rejects with the task's error
await blocker.run("q", async () => "still works"); // "still works"
blocker.isLocked("q"); // false — nothing left behind
The blocker never swallows, wraps or logs a task's error — the
rejection carries the exact value the task threw. Handle it
where you call run(), the same as any other
promise.
block#
block is the manual gear: it resolves once every
earlier holder of the queue has released, and hands you the
release function for your own hold. Between those
two moments the queue is yours:
const release = await blocker.block("file:write");
try {
await stream.write(chunk);
} finally {
release(); // always in a finally — see the warning below
}
Use it when acquire and release genuinely live in different
places — a connection that opens in one callback and closes in
another, a drag interaction that locks on
pointerdown and unlocks on pointerup.
For anything that fits inside one function, prefer
run().
The blocker cannot know a task is "done" — only
release() tells it. A hold whose release is never
called (an early return, an exception before the
try, a lost reference) keeps every later
block() and run() of that queue
pending forever. There is no timeout and no escape hatch by
design — wrap the critical section in
try/finally, or use
run() and let the library do exactly that.
The release function#
Each hold gets its own release. Calling it frees the
next waiter in line; calling it again is a safe
no-op — a double release can never free a waiter early or corrupt
the pending count:
const release = await blocker.block("q");
release();
release(); // no-op — the second waiter is not freed twice
When the last hold of a queue releases, the queue itself is
deleted — isLocked flips to false and
the id costs nothing until it is used again.
Independent queues#
The id names the resource being protected — only callers that share it line up. Ids are plain strings, so build them from your domain: one lock per user, per file, per remote endpoint.
blocker.run("user:42", updateProfile); // ┐ same id —
blocker.run("user:42", updateSettings); // ┘ run one after another
blocker.run("user:7", updateProfile); // different id — runs immediately
const perFile = (path: string) => `file:${path}`;
blocker.run(perFile("a.json"), write); // "file:a.json" and "file:b.json"
blocker.run(perFile("b.json"), write); // proceed in parallel
Calls without an id share one queue — "default",
exported as DEFAULT_QUEUE. That makes the zero-config
case just work for a single-resource app, and it composes with
explicit ids: block() and
block("default") are the same queue.
There is no "create queue" step: the first
block() / run() of an id brings its
queue into existence, and the release of the last hold deletes
it. An id that was used once and finished holds no memory — use
as many distinct ids as your data has keys.
isLocked & pending#
Two read-only probes into a queue's state — useful for badges, debug overlays and "save in progress…" indicators:
const release = await blocker.block("sync");
blocker.run("sync", push); // queued behind the manual hold
blocker.isLocked("sync"); // true — the queue is busy
blocker.pending("sync"); // 2 — one holding + one waiting
release();
// the queued task runs, releases…
blocker.pending("sync"); // 0
blocker.isLocked("sync"); // false
pending counts holds that have not released yet —
the one currently running plus everyone in line.
isLocked is simply "is there a queue for this id
right now".
if (!blocker.isLocked("q")) { … } is a race: the
answer can change between the check and your next line. To
coordinate work, enter the queue — run() /
block() — and let FIFO ordering do the guarding.
The probes are for observing, not deciding.
Patterns#
Refresh once, reuse everywhere#
The canonical case. Several requests hit a 401 at the same time; each wants a fresh token. Behind one queue, the first caller refreshes — the rest arrive after it finished, find the token valid, and return immediately:
const ensureToken = () =>
blocker.run("auth", async () => {
if (!isExpired(token)) return token; // later callers exit here
token = await api.refresh(); // network hit happens once
return token;
});
The shape to remember: re-check the condition inside the lock. The check before the queue told you refreshing might be needed; only the check inside tells you it still is.
Atomic read-modify-write#
Any "read, compute, write back" against shared storage — a file, IndexedDB, a remote counter — loses updates when two callers interleave. Give the resource a queue and the sequence becomes atomic:
const addToCart = (item: Item) =>
blocker.run("cart", async () => {
const cart = await db.get("cart"); // read
cart.items.push(item); // modify
await db.set("cart", cart); // write — nothing ran in between
});
Ordered side effects#
When effects must land in the order they were triggered — autosaves, analytics batches, messages over a socket — fire and forget into a queue. Callers don't wait for each other's completion, but the effects execute strictly in call order:
const autosave = (draft: Draft) => {
void blocker.run("autosave", () => api.save(draft));
};
autosave(v1); // saves run 1 → 2 → 3 even when
autosave(v2); // the network answers out of order
autosave(v3);
The library serializes — it does not debounce, retry, cancel or limit concurrency to N. Those belong a layer above: debounce before enqueueing, retry inside the task. What the queue guarantees is only — and exactly — one task at a time, in order.
TypeScript#
run() infers its result from the task — including
synchronous tasks, whose return value is wrapped into the
promise. block() resolves with a
Release:
const n = await blocker.run("q", async () => 42); // n: number
const s = await blocker.run(() => "sync"); // s: string
const release: Release = await blocker.block("q");
release(); // () => void
The blocker object is frozen (Object.freeze), so its
methods cannot be monkey-patched or reassigned — what you export
from blocker.ts is exactly what every consumer gets.
Exports#
Values
createExecutionBlocker · DEFAULT_QUEUE
Types
ExecutionBlocker · Release