Skip to content

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 ​

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

ts
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 a WithRunIdContext as the first argument, which includes the unique runId for 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, default false): When true, 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 leading WithRunIdContext).
  • <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 a Promise<WithRunIdResult<TResult>>
  • cancel() β€” clears the active execution and resolves all queued calls with { status: "canceled" }
  • isRunning β€” true when an execution is currently active
  • currentRunId β€” identifier of the currently active run, or null when idle

Type Declarations ​

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

StrategyWhat happens with new calls?Order guaranteedOld result used?Typical use case
dropIgnoredβŒβœ”Prevent spam clicks
queueAdded to queueβœ”βœ”Sequential operations
replaceReplaces 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 } β€” no try/catch needed at the call site.
  • true: errors are re-thrown β€” standard exception-based flow with try/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 AbortController manually
  • 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 withAbortable instead)
  • 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 ​

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 ​

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 ​

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();