Lightweight scheduler for running arbitrary work on a dynamic interval. Ideal for backoff retry loops, background autosaves, and heartbeat polling. Ensures runs never overlap and supports adaptive delays, jitter, and full lifecycle controls.
- Non-overlapping runs (no reentrancy) with automatic rescheduling after each run
- Dynamic delay calculation via strategies (exponential, linear, constant, or custom)
- Jitter support to avoid thundering herds (none, full, or bounded ±percentage)
- Lifecycle controls: start, pause, resume, stop, runNow, resetBackoff
- Success/failure-aware scheduling and optional min/max delay clamps
- Optional AbortSignalto stop cooperatively
- Local file usage:
- TypeScript/ESM: import { DynamicInterval, exponentialBackoff, constantInterval, linearBackoff } from "./dynamic-interval"
- CommonJS (ts-node/register or transpiled): const { DynamicInterval } = require("./dynamic-interval")
 
- TypeScript/ESM: 
Create a scheduler that retries with exponential backoff, bounded jitter, and resets on success.
import { DynamicInterval, exponentialBackoff } from "./dynamic-interval";
const di = new DynamicInterval({
  task: async () => {
    // do work that may fail
  },
  strategy: exponentialBackoff({
    initialDelayMs: 500,
    maxDelayMs: 30_000,
    resetOnSuccess: true,
  }),
  jitter: { kind: "bounded", percentage: 0.2 },
  autoStart: true,
  name: "retry-loop",
});import { DynamicInterval, exponentialBackoff } from "./dynamic-interval";
const retry = new DynamicInterval({
  task: async () => {
    // e.g., network call
  },
  strategy: exponentialBackoff({
    initialDelayMs: 500,
    maxDelayMs: 60_000,
    resetOnSuccess: true,
    factor: 2,
  }),
  jitter: { kind: "bounded", percentage: 0.3 },
  autoStart: true,
  name: "retry",
});import { DynamicInterval, constantInterval } from "./dynamic-interval";
const autosave = new DynamicInterval({
  task: async () => {
    /* save draft */
  },
  strategy: constantInterval(5_000),
  jitter: { kind: "full" },
  autoStart: true,
  name: "autosave",
});
// Trigger an immediate save without changing the schedule
autosave.runNow();import { DynamicInterval } from "./dynamic-interval";
const heartbeat = new DynamicInterval({
  task: () => {
    /* send heartbeat */
  },
  baseDelayMs: 10_000,
  runFirstImmediately: true,
});
heartbeat.start();
// later
heartbeat.pause();
heartbeat.resume();import { DynamicInterval } from "./dynamic-interval";
const controller = new AbortController();
const di = new DynamicInterval({
  task: async () => {
    /* work */
  },
  baseDelayMs: 1_000,
  signal: controller.signal,
  autoStart: true,
});
// later: cancel all future runs (in-flight run completes)
controller.abort();import { DynamicInterval, type BackoffStrategy } from "./dynamic-interval";
const myStrategy: BackoffStrategy = {
  nextDelayMs: ({ lastOutcome, lastDelayMs }) => {
    if (!lastOutcome) return 1000; // first run
    if (lastOutcome.ok) return 1000; // steady state on success
    // on failure: add 750ms up to 10s
    return Math.min(10_000, (lastDelayMs ?? 1000) + 750);
  },
  reset: () => {
    /* optional, reset internal state */
  },
};
const custom = new DynamicInterval({
  task: async () => {},
  strategy: myStrategy,
  autoStart: true,
});Constructor options:
- task: () => Promise<void> | void– The work to run each tick
- strategy?: BackoffStrategy– Computes pre-jitter delays; if omitted, usebaseDelayMs
- baseDelayMs?: number – Constant delay when no strategyis provided
- jitter?: JitterOptions–none|full|boundedwithpercentagein [0,1]
- autoStart?: boolean – Start immediately on construct (default false)
- minDelayMs? / maxDelayMs?: number – Clamp after jitter
- runFirstImmediately?: boolean – Run immediately on start()before scheduling
- signal?: AbortSignal– Abort to stop scheduling; in-flight run finishes
- name?: string – For debugging/logging
Methods:
- start(): void – Start scheduling (no-op if already started)
- pause(): void – Pause scheduling; no new runs are enqueued
- resume(): void – Resume scheduling
- stop(): void – Stop permanently; cannot be restarted
- runNow(): void – Queue an immediate run (does not alter subsequent schedule)
- resetBackoff(): void – Clear internal history and call strategy.reset() if provided
- info: getter – { name, paused, stopped, running, lastDelayMs, lastOutcome, runCount }
Behavioral notes:
- Runs never overlap; the next delay is scheduled only after the previous run completes.
- If taskthrows,okwill befalseinlastOutcomefor the next delay computation.
- constantInterval(delayMs: number): always returns- delayMs
- exponentialBackoff({ initialDelayMs, maxDelayMs, factor = 2, resetOnSuccess = false })- On failure (or first run), delay grows by factorup tomaxDelayMs
- If resetOnSuccessis true, resets toinitialDelayMsafter a successful run
 
- On failure (or first run), delay grows by 
- linearBackoff({ initialDelayMs, stepMs, maxDelayMs = Infinity, resetOnSuccess = false })- On failure, delay increases by stepMsup tomaxDelayMs
- If resetOnSuccessis true, resets toinitialDelayMsafter a successful run
 
- On failure, delay increases by 
- { kind: "none" }– no jitter
- { kind: "full" }– random in [0, baseDelay]
- { kind: "bounded", percentage }– random in [base*(1-p), base*(1+p)] where- p ∈ [0,1]
- Use runFirstImmediately: truefor latency-sensitive tasks (e.g., heartbeats) so the first run happens right away.
- Prefer some jitter in distributed deployments to avoid synchronized spikes.
- For idempotent tasks, runNow()can be used to respond to external triggers without changing cadence.
setInterval can cause overlapping runs if the task takes longer than the interval. DynamicInterval schedules the next run only after the current one finishes, making it safer for I/O-bound or variable-duration tasks.