Skip to content

Instantly share code, notes, and snippets.

@erikshestopal
Forked from ryanflorence/README.md
Created October 13, 2025 13:16
Show Gist options
  • Save erikshestopal/e6f6bc98692ed8ccf310d80fafde7931 to your computer and use it in GitHub Desktop.
Save erikshestopal/e6f6bc98692ed8ccf310d80fafde7931 to your computer and use it in GitHub Desktop.

DynamicInterval

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.

Features

  • 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 AbortSignal to stop cooperatively

Install / Use

  • Local file usage:
    • TypeScript/ESM: import { DynamicInterval, exponentialBackoff, constantInterval, linearBackoff } from "./dynamic-interval"
    • CommonJS (ts-node/register or transpiled): const { DynamicInterval } = require("./dynamic-interval")

Quick start

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",
});

Examples

1) Exponential backoff retry with jitter

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",
});

2) Background autosave with constant interval and full jitter

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

3) Heartbeat that runs immediately, then every 10s

import { DynamicInterval } from "./dynamic-interval";

const heartbeat = new DynamicInterval({
  task: () => {
    /* send heartbeat */
  },
  baseDelayMs: 10_000,
  runFirstImmediately: true,
});

heartbeat.start();
// later
heartbeat.pause();
heartbeat.resume();

4) Stop via AbortSignal

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

5) Custom backoff strategy

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,
});

API

class DynamicInterval

Constructor options:

  • task: () => Promise<void> | void – The work to run each tick
  • strategy?: BackoffStrategy – Computes pre-jitter delays; if omitted, use baseDelayMs
  • baseDelayMs?: number – Constant delay when no strategy is provided
  • jitter?: JitterOptionsnone | full | bounded with percentage in [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 task throws, ok will be false in lastOutcome for the next delay computation.

Strategy helpers

  • constantInterval(delayMs: number): always returns delayMs
  • exponentialBackoff({ initialDelayMs, maxDelayMs, factor = 2, resetOnSuccess = false })
    • On failure (or first run), delay grows by factor up to maxDelayMs
    • If resetOnSuccess is true, resets to initialDelayMs after a successful run
  • linearBackoff({ initialDelayMs, stepMs, maxDelayMs = Infinity, resetOnSuccess = false })
    • On failure, delay increases by stepMs up to maxDelayMs
    • If resetOnSuccess is true, resets to initialDelayMs after a successful run

Jitter options

  • { 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]

Tips

  • Use runFirstImmediately: true for 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.

Why not setInterval?

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.

/*
Lightweight scheduler for running arbitrary async work on a dynamically changing interval.
Features
- Dynamic delay: provide a function to compute the next delay based on last run result
- Backoff helpers: exponential or custom strategies via a pluggable calculator
- Jitter: optional full or bounded jitter to avoid thundering herds
- Controls: start, pause, resume, runNow, resetBackoff, stop
- Error-aware: distinguish success/failure for adaptive schedules
Use cases
- Network backoff retry loop
- Background autosave with adaptive cadence
- Heartbeat/health polling with jitter
Design notes
- The scheduler never overlaps runs; if the task is still running, the next tick is queued after completion
- All public methods are safe to call multiple times
*/
export type Millis = number;
export type RunOutcome = {
ok: boolean;
error?: unknown;
durationMs: Millis;
};
export type TaskFn = () => Promise<void> | void;
export type NextDelayInput = {
lastDelayMs: Millis | null;
lastOutcome: RunOutcome | null;
runCount: number; // completed runs
};
export type NextDelayFn = (input: NextDelayInput) => Millis;
export type JitterKind = "none" | "full" | "bounded";
export type JitterOptions =
| { kind: "none" }
| { kind: "full" }
| { kind: "bounded"; percentage: number }; // e.g. 0.2 => ±20%
export type BackoffStrategy = {
// Given context, returns the base (pre-jitter) delay for the next run
nextDelayMs: NextDelayFn;
// Optional hook to reset internal state (e.g., after success)
reset?: () => void;
};
export type DynamicIntervalOptions = {
// Task to be executed on each tick
task: TaskFn;
// Strategy to compute next delay (before jitter). If omitted, constant interval is required.
strategy?: BackoffStrategy;
// Constant base delay used if no strategy is provided
baseDelayMs?: Millis;
// Add jitter on top of computed delay
jitter?: JitterOptions;
// Start immediately when constructed
autoStart?: boolean;
// Minimum and maximum bounds applied after jitter
minDelayMs?: Millis;
maxDelayMs?: Millis;
// If true, the first run happens immediately; otherwise it waits for the first computed delay
runFirstImmediately?: boolean;
// Optional signal for cooperative cancellation
signal?: AbortSignal;
// Optional name for debugging/logging
name?: string;
};
function clamp(value: number, min?: number, max?: number): number {
if (min != null && value < min) return min;
if (max != null && value > max) return max;
return value;
}
function applyJitter(baseDelayMs: Millis, jitter?: JitterOptions): Millis {
if (!jitter || jitter.kind === "none") return baseDelayMs;
if (jitter.kind === "full") {
// random in [0, base]
return Math.floor(Math.random() * baseDelayMs);
}
if (jitter.kind === "bounded") {
// bounded ±percentage
const pct = Math.max(0, Math.min(1, jitter.percentage));
const delta = baseDelayMs * pct;
const min = baseDelayMs - delta;
const max = baseDelayMs + delta;
return Math.floor(min + Math.random() * (max - min));
}
return baseDelayMs;
}
export class DynamicInterval {
private readonly task: TaskFn;
private readonly strategy?: BackoffStrategy;
private readonly baseDelayMs?: Millis;
private readonly jitter?: JitterOptions;
private readonly minDelayMs?: Millis;
private readonly maxDelayMs?: Millis;
private readonly runFirstImmediately: boolean;
private readonly name?: string;
private timer: ReturnType<typeof setTimeout> | null = null;
private paused = true;
private running = false;
private stopped = false;
private lastDelayMs: Millis | null = null;
private lastOutcome: RunOutcome | null = null;
private runCount = 0;
private readonly abortSignal?: AbortSignal;
constructor(options: DynamicIntervalOptions) {
const {
task,
strategy,
baseDelayMs,
jitter,
minDelayMs,
maxDelayMs,
autoStart,
runFirstImmediately,
signal,
name,
} = options;
if (!strategy && (baseDelayMs == null || baseDelayMs < 0)) {
throw new Error("Provide either strategy or non-negative baseDelayMs");
}
this.task = task;
this.strategy = strategy;
this.baseDelayMs = baseDelayMs;
this.jitter = jitter;
this.minDelayMs = minDelayMs;
this.maxDelayMs = maxDelayMs;
this.runFirstImmediately = !!runFirstImmediately;
this.name = name;
this.abortSignal = signal;
if (this.abortSignal) {
this.abortSignal.addEventListener("abort", () => this.stop(), {
once: true,
});
}
if (autoStart) this.start();
}
// Public API
start(): void {
if (this.stopped) return; // cannot restart after stop
if (!this.paused) return; // already started
this.paused = false;
if (this.runFirstImmediately) {
this.queueImmediate();
} else {
this.scheduleNext();
}
}
pause(): void {
if (this.paused || this.stopped) return;
this.paused = true;
this.clearTimer();
}
resume(): void {
if (!this.paused || this.stopped) return;
this.paused = false;
this.scheduleNext();
}
stop(): void {
if (this.stopped) return;
this.stopped = true;
this.paused = true;
this.clearTimer();
}
runNow(): void {
if (this.stopped) return;
if (this.running) return; // avoid overlap; when current completes it will schedule
this.clearTimer();
this.queueImmediate();
}
resetBackoff(): void {
this.lastDelayMs = null;
this.lastOutcome = null;
if (this.strategy && this.strategy.reset) this.strategy.reset();
}
get info() {
return {
name: this.name,
paused: this.paused,
stopped: this.stopped,
running: this.running,
lastDelayMs: this.lastDelayMs,
lastOutcome: this.lastOutcome,
runCount: this.runCount,
} as const;
}
// Internals
private clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private queueImmediate() {
// Using setTimeout 0 to avoid reentrancy hazards
this.timer = setTimeout(() => this.tick(), 0);
}
private scheduleNext() {
if (this.paused || this.stopped) return;
const base = this.computeBaseDelay();
const withJitter = applyJitter(base, this.jitter);
const bounded = clamp(withJitter, this.minDelayMs, this.maxDelayMs);
this.lastDelayMs = bounded;
this.timer = setTimeout(() => this.tick(), bounded);
}
private computeBaseDelay(): Millis {
const input: NextDelayInput = {
lastDelayMs: this.lastDelayMs,
lastOutcome: this.lastOutcome,
runCount: this.runCount,
};
if (this.strategy) return Math.max(0, this.strategy.nextDelayMs(input));
// constant interval path
return Math.max(0, this.baseDelayMs as Millis);
}
private async tick() {
if (this.paused || this.stopped) return;
if (this.running) return; // should not happen because we never schedule while running
this.running = true;
const start = Date.now();
let outcome: RunOutcome;
try {
await this.task();
outcome = { ok: true, durationMs: Date.now() - start };
// On success, consumers may want to reset backoff state
// We don't auto-reset here to allow strategies to encode their own success handling
} catch (err) {
outcome = { ok: false, error: err, durationMs: Date.now() - start };
}
this.running = false;
this.lastOutcome = outcome;
this.runCount += 1;
if (!this.paused && !this.stopped) {
this.scheduleNext();
}
}
}
// Strategy helpers
export function constantInterval(delayMs: Millis): BackoffStrategy {
if (delayMs < 0) throw new Error("delay must be >= 0");
return {
nextDelayMs: () => delayMs,
};
}
export type ExponentialBackoffOptions = {
initialDelayMs: Millis; // starting delay
maxDelayMs: Millis; // cap
factor?: number; // growth factor (default 2)
// If true, reset to initialDelayMs after a successful run
resetOnSuccess?: boolean;
};
export function exponentialBackoff(
options: ExponentialBackoffOptions,
): BackoffStrategy {
const factor = options.factor ?? 2;
let current = clamp(options.initialDelayMs, 0, options.maxDelayMs);
return {
nextDelayMs: ({ lastOutcome, runCount }) => {
// Use initial delay on the very first schedule
if (runCount === 0) {
return current;
}
if (lastOutcome?.ok) {
// success path handled below depending on resetOnSuccess
if (options.resetOnSuccess) {
current = options.initialDelayMs;
}
return current;
}
// On failure or first run, increase delay
const next = Math.min(
options.maxDelayMs,
Math.max(options.initialDelayMs, current) * factor,
);
current = next;
return current;
},
reset: () => {
current = options.initialDelayMs;
},
};
}
export type LinearBackoffOptions = {
initialDelayMs: Millis;
stepMs: Millis;
maxDelayMs?: Millis;
resetOnSuccess?: boolean;
};
export function linearBackoff(options: LinearBackoffOptions): BackoffStrategy {
let current = Math.max(0, options.initialDelayMs);
const max = options.maxDelayMs ?? Number.POSITIVE_INFINITY;
return {
nextDelayMs: ({ lastOutcome, runCount }) => {
// Use initial delay on first schedule
if (runCount === 0) return current;
if (lastOutcome?.ok && options.resetOnSuccess) {
current = options.initialDelayMs;
return current;
}
current = Math.min(max, current + options.stepMs);
return current;
},
reset: () => {
current = options.initialDelayMs;
},
};
}
/*
JSDoc examples
// 1) Exponential backoff with jitter for retry loop
const scheduler = new DynamicInterval({
task: async () => {
// do work that may fail
},
strategy: exponentialBackoff({ initialDelayMs: 500, maxDelayMs: 30_000, resetOnSuccess: true }),
jitter: { kind: "bounded", percentage: 0.2 },
minDelayMs: 100,
maxDelayMs: 60_000,
autoStart: true,
name: "retry-loop",
});
// 2) Autosave every 5s with full jitter to spread load
const autosave = new DynamicInterval({
task: saveDraft,
strategy: constantInterval(5_000),
jitter: { kind: "full" },
autoStart: true,
name: "autosave",
});
// 3) Heartbeat that runs immediately, then every 10s; supports pause/resume
const heartbeat = new DynamicInterval({
task: sendHeartbeat,
baseDelayMs: 10_000,
runFirstImmediately: true,
});
heartbeat.start();
// later
heartbeat.pause();
heartbeat.resume();
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment