execution-blocker
execution-blocker 1.0.0

Promise-based FIFO execution lock with independent queues per id. Zero dependencies.

Contents

Contents

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.

strict FIFO order independent queues per id errors can't jam the queue framework-free zero dependencies
A lock must be released

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#

sh
npm i @dmytromykhailiuk/execution-blocker
Requirements

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.

ts
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:

tsthe race an execution blocker removes
// 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:

tsblocker.ts — one instance per domain
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#

ts
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).

run<T>(id?: string, fn: () => T | Promise<T>) => Promise<T>

Acquire the id queue, execute fn, release — even when fn throws. Resolves with fn's result. The safe default. See run.

block(id?: string) => Promise<Release>

Wait for every earlier holder of id, then acquire the lock. Resolves with the release function for this hold — you are responsible for calling it. See block.

isLocked(id?: string) => boolean

Whether anything currently holds or waits for the id queue. See isLocked & pending.

pending(id?: string) => number

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:

ts
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:

ts
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:

ts
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
Errors stay yours

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:

ts
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().

Forgetting release() deadlocks the queue

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:

ts
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.

ts
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.

Queues appear and disappear on their own

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:

ts
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".

Probes are not guards

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:

ts
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:

ts
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:

ts
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);
This is a lock, not a scheduler

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:

ts
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