Skip to content

Instantly share code, notes, and snippets.

@sonhanguyen
Last active February 4, 2026 09:31
Show Gist options
  • Select an option

  • Save sonhanguyen/d26249e1675dcc5c49df03362e035dbe to your computer and use it in GitHub Desktop.

Select an option

Save sonhanguyen/d26249e1675dcc5c49df03362e035dbe to your computer and use it in GitHub Desktop.
TypeScript Debouncer class with subscription support and comprehensive tests
export class Debouncer<T> {
private timeout!: ReturnType<typeof setTimeout>;
private promises = Array<Promise<T>>();
private subscriptions = new Set<{
onNext: (value: T, stale: boolean, id: number) => void;
onError?: (error: any) => void;
}>();
private sequenceId = 0;
constructor(public defaultDelay?: number) {}
schedule(todo: () => Promise<T>, delay = this.defaultDelay) {
if (this.timeout) clearTimeout(this.timeout);
const id = this.sequenceId++;
this.timeout = setTimeout(async () => {
this.timeout = 0;
const promise = todo();
this.promises.push(promise);
try {
const value = await promise;
const stale = id < this.sequenceId - 1;
this.subscriptions.forEach((sub) => {
try {
sub.onNext(value, stale, id);
} catch (err) {
sub.onError?.(err);
}
});
} catch (error) {
this.subscriptions.forEach((sub) => sub.onError?.(error));
} finally {
this.promises = this.promises.filter((toKeep) => toKeep != promise);
}
}, delay);
return id;
}
subscribe(
onNext: (value: T, stale: boolean, id: number) => void,
onError?: (error: any) => void,
): () => void {
const subscription = { onNext, onError };
this.subscriptions.add(subscription);
return () => this.subscriptions.delete(subscription);
}
}
try {
const { test, expect, describe } = await import("bun:test");
describe("Debouncer", () => {
test("debounces multiple calls and only executes the last one", async () => {
const debouncer = new Debouncer<number>(50);
const results: Array<{ value: number; id: number; stale: boolean }> = [];
debouncer.subscribe((value, stale, id) => {
results.push({ value, stale, id });
});
debouncer.schedule(() => Promise.resolve(1));
debouncer.schedule(() => Promise.resolve(2));
debouncer.schedule(() => Promise.resolve(3));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(results.length).toBe(1);
expect(results[0]).toEqual({ value: 3, id: 2, stale: false });
});
test("marks earlier results as stale when they resolve after newer calls", async () => {
const debouncer = new Debouncer<number>(50);
const results: Array<{ value: number; id: number; stale: boolean }> = [];
debouncer.subscribe((value, stale, id) => {
results.push({ value, stale, id });
});
// First call resolves slowly
debouncer.schedule(
() => new Promise((resolve) => setTimeout(() => resolve(1), 150)),
);
await new Promise((resolve) => setTimeout(resolve, 60));
// Second call resolves quickly
debouncer.schedule(() => Promise.resolve(2));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(results.length).toBe(2);
expect(results[0]).toEqual({ value: 2, id: 1, stale: false });
expect(results[1]).toEqual({ value: 1, id: 0, stale: true });
});
test("supports multiple subscribers", async () => {
const debouncer = new Debouncer<string>(50);
const results1: string[] = [];
const results2: string[] = [];
debouncer.subscribe((value) => results1.push(value));
debouncer.subscribe((value) => results2.push(value));
debouncer.schedule(() => Promise.resolve("test"));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(results1).toEqual(["test"]);
expect(results2).toEqual(["test"]);
});
test("allows unsubscribing", async () => {
const debouncer = new Debouncer<string>(50);
const results: string[] = [];
const unsubscribe = debouncer.subscribe((value) => results.push(value));
debouncer.schedule(() => Promise.resolve("before"));
await new Promise((resolve) => setTimeout(resolve, 100));
unsubscribe();
debouncer.schedule(() => Promise.resolve("after"));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(results).toEqual(["before"]);
});
test("handles errors through onError callback", async () => {
const debouncer = new Debouncer<number>(50);
const errors: any[] = [];
debouncer.subscribe(
() => {},
(error) => errors.push(error),
);
debouncer.schedule(() => Promise.reject(new Error("test error")));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(errors.length).toBe(1);
expect(errors[0].message).toBe("test error");
});
test("only notifies the subscriber whose onNext throws", async () => {
const debouncer = new Debouncer<number>(50);
const errors1: any[] = [];
const errors2: any[] = [];
const results: number[] = [];
debouncer.subscribe(
() => {
throw new Error("subscriber1 error");
},
(err) => errors1.push(err),
);
debouncer.subscribe(
(value) => results.push(value),
(err) => errors2.push(err),
);
debouncer.schedule(() => Promise.resolve(42));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(errors1.length).toBe(1);
expect(errors1[0].message).toBe("subscriber1 error");
expect(errors2.length).toBe(0);
expect(results).toEqual([42]);
});
test("does not call onError of other subscribers if one onNext throws", async () => {
const debouncer = new Debouncer<number>(50);
const errorsA: any[] = [];
const errorsB: any[] = [];
debouncer.subscribe(
() => {
throw new Error("A error");
},
(err) => errorsA.push(err),
);
debouncer.subscribe(
() => {},
(err) => errorsB.push(err),
);
debouncer.schedule(() => Promise.resolve(1));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(errorsA.length).toBe(1);
expect(errorsA[0].message).toBe("A error");
expect(errorsB.length).toBe(0);
});
test("uses default delay from constructor", async () => {
const debouncer = new Debouncer<number>(100);
const results: number[] = [];
debouncer.subscribe((value) => results.push(value));
const start = Date.now();
debouncer.schedule(() => Promise.resolve(1));
await new Promise((resolve) => setTimeout(resolve, 150));
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(100);
expect(results).toEqual([1]);
});
test("allows overriding delay per call", async () => {
const debouncer = new Debouncer<number>(500);
const results: number[] = [];
debouncer.subscribe((value) => results.push(value));
const start = Date.now();
debouncer.schedule(() => Promise.resolve(1), 50);
await new Promise((resolve) => setTimeout(resolve, 100));
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(500);
expect(results).toEqual([1]);
});
test("returns unique sequential IDs", () => {
const debouncer = new Debouncer<number>(50);
const id1 = debouncer.schedule(() => Promise.resolve(1));
const id2 = debouncer.schedule(() => Promise.resolve(2));
const id3 = debouncer.schedule(() => Promise.resolve(3));
expect(id1).toBe(0);
expect(id2).toBe(1);
expect(id3).toBe(2);
});
test("cleans up promises after they complete", async () => {
const debouncer = new Debouncer<number>(50);
debouncer.subscribe(() => {});
debouncer.schedule(() => Promise.resolve(1));
await new Promise((resolve) => setTimeout(resolve, 100));
// Access private field for testing
const promises = (debouncer as any).promises;
expect(promises.length).toBe(0);
});
});
} catch {
// Not in a test environment
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment