Skip to content
LogoLogo

@portable/timers

Functions

Timers.backoff

Checks delay and returns a function that takes the attempt number, starting at 1, and the error that ended that attempt, and returns the wait in ms before the next attempt. retry and poll use it, and so do the reconnecting clients in @portable/websockets and @portable/sse.

function backoff(delay: Delay): (attempt: Attempt) => number;

Throws

  • RangeError When a wait, initial or max is negative, or factor isn't a positive finite number.
import * as Timers from '@portable/timers';
 
const next = Timers.backoff({
  initial: 1000,
  max: 30_000,
  jitter: false,
});
next({ attempt: 1 }); // 1000
next({ attempt: 3 }); // 4000

Timers.debounce

Wraps fn so that a burst of calls invokes it once, ms after the last call, with the last call's arguments. Every call in the burst resolves with that invocation's result.

function debounce<args extends unknown[], result, self = unknown>(
  fn: (this: self, ...args: args) => result,
  ms: number,
  options: debounce.Options = {},
): Debounced<args, result, self>;
OptionTypeDefaultDescription
leadingbooleanfalseInvoke on the first call of a burst.
trailingbooleantrueInvoke ms after the last call of a burst, with that call's arguments.
maxWaitnumberLongest time, in ms, that a call waits before an invocation, however often calls arrive.
signalAbortSignalCancels the waiting invocation, as cancel() does but rejecting with signal.reason, and disables the function.
import * as Timers from '@portable/timers';
 
const save = Timers.debounce(
  (text: string) => fetch('/draft', { method: 'PUT', body: text }),
  500,
);
editor.addEventListener('input', () => save(editor.value));
const response = await save(editor.value); // the response of the save that this call shares

Timers.poll

Calls fn until it returns a value other than undefined, waiting delay between calls, and resolves with that value. An error thrown by fn rejects the poll without another call.

async function poll<value>(
  fn: (
    context: Context,
  ) => value | undefined | PromiseLike<value | undefined>,
  options: poll.Options = {},
): Promise<value>;
OptionTypeDefaultDescription
delayDelay1000Wait after each check that returns undefined.
signalAbortSignalStops polling and rejects with signal.reason, also while a check is running. Pass AbortSignal.timeout(ms) to limit the whole poll.
import * as Timers from '@portable/timers';
 
const order = await Timers.poll(
  async ({ signal }) => {
    const order = await (
      await fetch(`/orders/${id}`, { signal })
    ).json();
    return order.status === 'paid' ? order : undefined;
  },
  { delay: 2000, signal: AbortSignal.timeout(120_000) },
);

Timers.retry

Calls fn until it returns without throwing, waiting between attempts. When every attempt fails, rejects with the last error unchanged.

async function retry<value>(
  fn: (context: Context) => value | PromiseLike<value>,
  options: retry.Options = {},
): Promise<value>;
OptionTypeDefaultDescription
attemptsnumber3Total number of attempts, including the first. Infinity retries until success or abort.
delayDelay{ initial: 200, max: 10_000 }Wait before each retry.
retryIf((error: unknown, retry: { attempt: number; }) => boolean | PromiseLike<boolean>)() => trueCalled before each retry with the error and { attempt }, the number of the attempt that threw it. Return false to stop and throw that error. It isn't called after the last attempt.
onRetry((error: unknown, retry: retry.Retry) => void | PromiseLike<void>)Called after retryIf decides to retry and before the wait, with the error and { attempt, delay }: the number of the attempt that threw it, and the wait in ms. Use it to log retries. If onRetry returns a promise, the next attempt waits for it. If onRetry throws, retry rejects with that error.
timeoutnumberInfinityTime limit for each attempt, in ms. When it passes, the attempt's signal aborts with a TimeoutError DOMException, and the attempt fails with it.
signalAbortSignalStops retrying and rejects with signal.reason, also while an attempt is running.
import * as Timers from '@portable/timers';
 
const response = await Timers.retry(
  async ({ signal }) => {
    const response = await fetch('https://example.com/api', { signal });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return response;
  },
  {
    attempts: 5,
    timeout: 10_000,
    onRetry: (error, { attempt, delay }) =>
      console.warn(attempt, delay, error),
  },
);

Timers.sleep

Resolves after ms milliseconds. Uses scheduler.wait where the runtime has it, and setTimeout otherwise. Delays longer than setTimeout's limit of about 24.8 days, including Infinity, are waited out in steps, because setTimeout would fire such a timer at once.

async function sleep(
  ms: number,
  options: sleep.Options = {},
): Promise<void>;
OptionTypeDefaultDescription
signalAbortSignalRejects the returned promise with signal.reason and clears the timer when aborted.
import * as Timers from '@portable/timers';
 
await Timers.sleep(1000);
await Timers.sleep(5000, { signal: AbortSignal.timeout(1000) }); // rejects with a TimeoutError

Timers.throttle

Wraps fn so that it runs at most once every ms milliseconds, however often it is called. Calls that share an invocation all resolve with its result.

function throttle<args extends unknown[], result, self = unknown>(
  fn: (this: self, ...args: args) => result,
  ms: number,
  options: throttle.Options = {},
): Debounced<args, result, self>;
OptionTypeDefaultDescription
leadingbooleantrueInvoke on the first call of a burst.
trailingbooleantrueInvoke once more at the end of each ms window that had calls, with the last call's arguments.
signalAbortSignalCancels the waiting invocation, as cancel() does but rejecting with signal.reason, and disables the function.
import * as Timers from '@portable/timers';
 
const report = Timers.throttle(
  () => fetch('/position', { method: 'POST', body: String(scrollY) }),
  1000,
);
addEventListener('scroll', () => report());

Types

Timers.Attempt

What a delay function receives.

type Attempt = {
  attempt: number;
  error?: unknown;
};

Timers.Backoff

Exponential backoff. Attempt n waits up to min(max, initial * factor ** (n - 1)) ms. With jitter (the default), the wait is a random value between 0 and that cap ("full jitter").

type Backoff = {
  initial: number;
  max: number;
  factor?: number | undefined;
  jitter?: boolean | undefined;
};

Timers.Context

What retry and poll pass to fn.

type Context = {
  attempt: number;
  signal: AbortSignal;
};

Timers.Delay

How long to wait before the next attempt: a fixed number of milliseconds, exponential backoff, or a function that takes { attempt, error }: the number of the attempt that just finished and, in retry, the error it threw.

type Delay = number | Backoff | ((attempt: Attempt) => number);

Timers.Instant

A point in time that an option such as now or expiresAt accepts: a Date, or any object with epochMilliseconds, such as a Temporal.Instant or Temporal.ZonedDateTime.

type Instant =
  | Date
  | {
      readonly epochMilliseconds: number;
    };

Timers.Debounced

A debounced or throttled function. Calls that arrive while an invocation is waiting share it, and each one returns a promise of that invocation's result. Where the runtime has Symbol.dispose, [Symbol.dispose]() calls cancel(), so a function declared with using drops its waiting invocation when the scope ends.

type Debounced<
  args extends unknown[],
  result,
  self = unknown,
> = DisposeMethod & {
  (this: self, ...args: args): Promise<Awaited<result>>;
  cancel(): void;
  flush(): Promise<Awaited<result> | undefined>;
  readonly pending: boolean;
};