Category: async Since: 1.0.0 Tags: async, concurrency, race-condition, queue, drop, replace, run-id, strategy
withRunId β
Wraps a function with controlled execution semantics based on a configurable concurrency strategy.
Usage β
import { withRunId } from "@petr-ptacek/js-core";
const fetchUser = withRunId(
async (_ctx, id: string) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
},
{ strategy: "replace" }
);
const result = await fetchUser.execute("123");
if (result.status === "success") {
console.log(result.result);
}Why This Utility Exists β
Async functions are frequently triggered multiple times before the previous invocation completes β form submissions, search inputs, data polling. Without a concurrency control mechanism, this leads to race conditions, stale results, or redundant executions.
This utility solves the problem by assigning each execution a unique runId and applying a configurable strategy that determines what happens to concurrent calls: ignore them, queue them, or replace the previous one.
π§ Simple explanation β
Imagine you press a button many times:
- Should it ignore extra clicks?
- Should it remember them and run later?
- Or should it stop the old work and only care about the newest click?
That is exactly what the strategies solve.
Signature β
function withRunId<TArgs extends unknown[], TResult>(
fn: (ctx: WithRunIdContext, ...args: TArgs) => MaybePromise<TResult>,
options?: WithRunIdOptions
): WithRunIdReturn<TArgs, TResult>;Parameters β
fn((ctx: WithRunIdContext, ...args: TArgs) => MaybePromise<TResult>): The function to wrap. Receives aWithRunIdContextas the first argument, which includes the uniquerunIdfor the current execution.options(WithRunIdOptions, optional): Configuration object.strategy(WithRunIdStrategy, default"drop"): Controls how concurrent invocations are handled.signal(AbortSignal, optional): External cancellation signal. Checked before each execution starts.throwOnError(boolean, defaultfalse): Whentrue, errors are re-thrown instead of being returned as{ status: "error", error }.
Type Parameters β
<TArgs extends unknown[]>: Argument types of the wrapped function (excluding the leadingWithRunIdContext).<TResult>: Return type of the wrapped function.
Return Type β
Returns a WithRunIdReturn<TArgs, TResult> controller object:
execute(...args)β runs the function according to the configured strategy and returns aPromise<WithRunIdResult<TResult>>cancel()β clears the active execution and resolves all queued calls with{ status: "canceled" }isRunningβtruewhen an execution is currently activecurrentRunIdβ identifier of the currently active run, ornullwhen idle
Type Declarations β
type WithRunIdStrategy = "drop" | "queue" | "replace";
interface WithRunIdOptions {
strategy?: WithRunIdStrategy;
signal?: AbortSignal;
throwOnError?: boolean;
}
interface WithRunIdContext {
runId: number;
}
type WithRunIdResult<TResult> =
| { status: "success"; result: TResult }
| { status: "skipped" }
| { status: "replaced" }
| { status: "canceled" }
| { status: "error"; error: unknown };
interface WithRunIdReturn<TArgs extends unknown[], TResult> {
execute: (...args: TArgs) => Promise<WithRunIdResult<TResult>>;
cancel: () => void;
readonly isRunning: boolean;
readonly currentRunId: number | null;
}Design Notes β
Strategies β
"drop" β
π βIgnore new calls if something is already running.β
- First call β runs
- Next calls β ignored
Use this when:
- You want to prevent spam (e.g. button clicks)
- Only one execution matters
"queue" β
π βWait your turn.β
- First call β runs
- Next calls β wait in line
- Everything runs one by one
Use this when:
- Order matters
- Nothing should be lost
"replace" β
π βOnly the latest call matters.β
- First call β starts
- New call β replaces it
- Old result is ignored
β οΈ Important:
- Old function is not stopped
- Its result is just ignored
Use this when:
- You only care about the latest result (e.g. search input)
Comparison β
| Strategy | What happens with new calls? | Order guaranteed | Old result used? | Typical use case |
|---|---|---|---|---|
| drop | Ignored | β | β | Prevent spam clicks |
| queue | Added to queue | β | β | Sequential operations |
| replace | Replaces current | β | β (ignored) | Search / latest wins |
Stale result protection β
Each execution receives a unique, monotonically increasing runId. When a result is resolved, the implementation checks whether runId still matches the active one. If not, the result is ignored and { status: "replaced" } is returned. This prevents outdated responses from overwriting newer data.
Cancellation semantics β
Calling cancel() clears the active run and resolves all pending queued calls with { status: "canceled" }. It does * *not** cancel the underlying async operation β it only discards its result. For actual cancellation, combine with AbortSignal passed to the wrapped function.
Error handling β
Controlled by throwOnError:
false(default): errors are captured and returned as{ status: "error", error }β notry/catchneeded at the call site.true: errors are re-thrown β standard exception-based flow withtry/catch.
When To Use β
Use withRunId when you need:
- protection against race conditions in repetitive async calls
- sequential execution of async operations (e.g. API mutations)
- "latest wins" semantics without managing
AbortControllermanually - explicit, typed execution outcomes for every invocation
- spam protection for async triggers
When Not To Use β
Avoid when:
- a single, one-off async call is sufficient
- you need real cancellation of the underlying operation (use
withAbortableinstead) - parallel concurrent executions are intentional
- synchronous functions without concurrency concerns
Summary β
withRunId provides deterministic concurrency control for async functions via configurable strategies (drop, queue, replace). Each execution returns an explicit typed result describing its outcome, eliminating the need for ad-hoc race condition workarounds.
Snippets β
basic.ts β
import { withRunId } from "@petr-ptacek/js-core";
// Wrap an async function with default "drop" strategy
const fetchUser = withRunId(async (_ctx, id: string) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return response.json() as Promise<{ id: string; name: string }>;
});
// Execute and handle the result via discriminated union
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _example() {
const result = await fetchUser.execute("123");
switch (result.status) {
case "success":
console.log("User:", result.result);
break;
case "skipped":
console.log("Execution was skipped β a run is already active");
break;
case "error":
console.error("Execution failed:", result.error);
break;
}
}
// Check execution state
console.log("Is running before:", fetchUser.isRunning); // false
const promise = fetchUser.execute("456");
console.log("Is running during:", fetchUser.isRunning); // true
promise.finally(() => {
console.log("Is running after:", fetchUser.isRunning); // false
});
// _example();error-handling.ts β
import { withRunId } from "@petr-ptacek/js-core";
// --- Result-based error handling (default, throwOnError: false) ---
// Errors are captured and returned as { status: "error", error }.
// No try/catch needed at the call site.
const loadData = withRunId(async (_ctx, id: string) => {
const response = await fetch(`/api/data/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<unknown>;
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _resultBasedExample() {
const result = await loadData.execute("42");
if (result.status === "success") {
console.log("Data loaded:", result.result);
return;
}
if (result.status === "error") {
console.error("Failed to load data:", result.error);
return;
}
// handle skipped / replaced / canceled
console.warn("Execution did not complete:", result.status);
}
// --- Exception-based error handling (throwOnError: true) ---
// Errors are re-thrown and must be caught with try/catch.
const loadDataStrict = withRunId(
async (_ctx, id: string) => {
const response = await fetch(`/api/data/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<unknown>;
},
{ throwOnError: true }
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _throwExample() {
try {
const result = await loadDataStrict.execute("42");
if (result.status === "success") {
console.log("Data loaded:", result.result);
}
} catch (error) {
console.error("Caught error:", error);
}
}
// --- Using an external AbortSignal ---
// Combine withRunId with an AbortController for real cancellation.
const controller = new AbortController();
const fetchWithSignal = withRunId(
async (_ctx, url: string) => {
// Pass the signal to the underlying fetch for real cancellation
const response = await fetch(url, { signal: controller.signal });
return response.json() as Promise<unknown>;
},
{ signal: controller.signal }
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _abortSignalExample() {
// Abort after 2 seconds
setTimeout(() => controller.abort(), 2000);
const result = await fetchWithSignal.execute("https://api.example.com/data");
if (result.status === "canceled") {
console.log("Execution was canceled via AbortSignal");
}
}
// _resultBasedExample();
// _throwExample();
// _abortSignalExample();strategies.ts β
import { withRunId } from "@petr-ptacek/js-core";
// --- Strategy: "drop" ---
// New calls are ignored while a run is active.
// Useful for preventing button spam or duplicate submissions.
const submitForm = withRunId(
async (_ctx, data: Record<string, string>) => {
console.log("Submitting form...", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
return { success: true };
},
{ strategy: "drop" }
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _dropExample() {
const [result1, result2] = await Promise.all([
submitForm.execute({ name: "Alice" }),
submitForm.execute({ name: "Bob" }), // ignored β first call still running
]);
console.log(result1); // { status: "success", result: { success: true } }
console.log(result2); // { status: "skipped" }
}
// --- Strategy: "queue" ---
// Each call waits for the previous one to finish before starting.
// Useful for sequential writes or ordered mutations.
const saveRecord = withRunId(
async (_ctx, record: { id: number; value: string }) => {
console.log(`Saving record ${record.id}...`);
await new Promise((resolve) => setTimeout(resolve, 300));
return { saved: true, id: record.id };
},
{ strategy: "queue" }
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _queueExample() {
// All three calls will execute β one after another, in order
const [r1, r2, r3] = await Promise.all([
saveRecord.execute({ id: 1, value: "a" }),
saveRecord.execute({ id: 2, value: "b" }),
saveRecord.execute({ id: 3, value: "c" }),
]);
console.log(r1); // { status: "success", result: { saved: true, id: 1 } }
console.log(r2); // { status: "success", result: { saved: true, id: 2 } }
console.log(r3); // { status: "success", result: { saved: true, id: 3 } }
}
// --- Strategy: "replace" ---
// A new call starts immediately and the previous result is discarded.
// Useful for search inputs or "latest wins" scenarios.
const search = withRunId(
async (_ctx, query: string) => {
console.log(`Searching for: "${query}"...`);
await new Promise((resolve) => setTimeout(resolve, 500));
return [`result for "${query}"`];
},
{ strategy: "replace" }
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
async function _replaceExample() {
// Simulate rapid user typing
const [r1, r2, r3] = await Promise.all([
search.execute("j"),
search.execute("jo"), // replaces "j"
search.execute("joh"), // replaces "jo"
]);
console.log(r1); // { status: "replaced" }
console.log(r2); // { status: "replaced" }
console.log(r3); // { status: "success", result: ['result for "joh"'] }
}
// _dropExample();
// _queueExample();
// _replaceExample();