cd /news/developer-tools/i-made-a-promise-aware-debounce-and-… · home topics developer-tools article
[ARTICLE · art-83650] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

I made a Promise-aware debounce and throttle library for TypeScript

Alsoftworks released temporize, a Promise-aware debounce and throttle library for TypeScript with zero runtime dependencies and ESM/CommonJS support. The library provides debounce, throttle, rafThrottle, debounceAsync, batch, retry, idle, and concurrencyLimit utilities, each returning real promises with inferred types. It offers features like cancellation, AbortSignal support, maxWait, and async overlap policies, distinguishing it from lodash's per-method packages.

read13 min views1 publishedAug 2, 2026
I made a Promise-aware debounce and throttle library for TypeScript
Image: source

Small, strongly typed timing and concurrency utilities for modern TypeScript. Every scheduled call returns a real promise for the wrapped function's actual result. There are no runtime dependencies, and the package ships as ESM and CommonJS.

npm install @alsoftworks/temporize
Capability temporize lodash.debounce / lodash.throttle
Inferred arguments and resolved return value Full generic inference Types available separately
Per-call return Promise<Awaited<R>>
Last synchronous result or undefined
Async error propagation Native promise rejection Caller manages returned value
Cancellation .cancel() and AbortSignal
.cancel()
Maximum debounce wait Yes Yes
Promise queue rate limiter throttlePromise
No
Async overlap policy debounceAsync
No
Animation-frame throttling Browser API plus Node fallback No
Multi-call argument batching batch
No
Exponential-backoff retries retry
No
Idle-period scheduling idle with Safari/Node fallback
No
Concurrent promise limiting concurrencyLimit with FIFO queue
No
Runtime dependencies Zero Zero for per-method packages
Modules ESM and CommonJS CommonJS per-method packages

temporize deliberately focuses on controlling when and how often work runs. Debouncing, throttling, batching, retry timing, frame scheduling, and idle scheduling all belong to that family. Object, array, string, and other general utility helpers are intentionally out of scope; that boundary is a design decision, not an omission.

import { debounce } from "@alsoftworks/temporize";

const search = debounce(
  async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    return response.json() as Promise<{ total: number }>;
  },
  250,
  { maxWait: 1_000 },
);

const result = await search("type inference");
console.log(result.total);

search.pending();
await search.flush();
search.cancel();

Several calls coalesced into one invocation each receive a separate promise settled with that invocation's result. A leading-only debounce resolves calls suppressed within the window with the leading invocation's result.

import { throttle } from "@alsoftworks/temporize";

const savePosition = throttle(
  (x: number, y: number) => ({ x, y, savedAt: Date.now() }),
  100,
  { leading: true, trailing: true },
);

const saved = await savePosition(120, 80);

Regular throttling coalesces excess calls and uses the latest arguments for a trailing invocation.

import { rafThrottle } from "@alsoftworks/temporize";

const updateLayout = rafThrottle((width: number) => {
  document.documentElement.style.setProperty("--viewport-width", `${width}px`);
});

window.addEventListener("resize", () => updateLayout(window.innerWidth));

// Discard a frame that has been requested but has not run.
updateLayout.cancel();

In browsers, rafThrottle

uses requestAnimationFrame

. During SSR and in Node, it falls back to a 16 ms setTimeout

, so importing and calling it is safe when animation-frame globals do not exist.

import { debounceAsync } from "@alsoftworks/temporize";

const loadUser = debounceAsync(
  async (id: string, signal: AbortSignal) => {
    const response = await fetch(`/api/users/${id}`, { signal });
    return response.json() as Promise<{ id: string; name: string }>;
  },
  200,
  { overlap: "cancel-previous" },
);

// The AbortSignal parameter is supplied internally and omitted from this call.
const user = await loadUser("user_123");

The overlap policies are:

"queue"

(default): keep each fired invocation and start it after active work settles."drop"

: do not start the overlapping invocation; its callers adopt the active invocation's promise."cancel-previous"

: abort the active invocation's internally supplied signal and immediately start the new invocation. JavaScript functions that do not declare a signal safely ignore the extra argument.

For typed signal injection, declare a required AbortSignal

as the wrapped function's final parameter. debounceAsync

removes that parameter from the returned function's call signature. Cancellation is cooperative: an async operation must observe the signal to stop its in-flight work.

import { throttlePromise } from "@alsoftworks/temporize";

const sendRequest = throttlePromise(
  async (path: string) => fetch(path).then((response) => response.status),
  100,
);

const requests = [
  sendRequest("/api/one"),
  sendRequest("/api/two"),
  sendRequest("/api/three"),
];

console.log(sendRequest.queued());
console.log(await Promise.all(requests));

Every call enters a FIFO queue and starts at least one window after the prior start. The queue limits dispatch rate, not concurrency: a slow promise may still be active when the next window begins.

import { batch } from "@alsoftworks/temporize";

const markNotificationsRead = batch(
  async (calls: Array<[notificationId: string]>) => {
    const ids = calls.map(([notificationId]) => notificationId);
    const response = await fetch("/api/notifications/read", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ ids }),
    });
    if (!response.ok) throw new Error("Could not mark notifications as read");
    return ids.length;
  },
  50,
  { maxSize: 100 },
);

const first = markNotificationsRead("notification_1");
const second = markNotificationsRead("notification_2");

// Both promises resolve to 2 because both calls shared one batch invocation.
console.log(await first, await second);

Unlike debounce

, batch

retains every argument tuple. The wrapped function runs once with the complete array, and every caller in that batch receives the same result or error.

import { retry } from "@alsoftworks/temporize";

const controller = new AbortController();
const fetchJson = retry(
  async (url: string) => {
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) throw new Error(`Request failed: ${response.status}`);
    return response.json() as Promise<unknown>;
  },
  {
    attempts: 4,
    baseDelay: 250,
    maxDelay: 2_000,
    signal: controller.signal,
  },
);

const data = await fetchJson("/api/flaky-report");

Retries use exponential backoff and randomized jitter by default. Supply shouldRetry

when permanent errors should stop immediately.

import { idle } from "@alsoftworks/temporize";

const recordAnalytics = idle(
  (eventName: string, properties: object) => {
    navigator.sendBeacon("/analytics", JSON.stringify({ eventName, properties }));
  },
  { timeout: 2_000 },
);

recordAnalytics("dashboard_viewed", { source: "navigation" });

Rapid calls are coalesced using the latest arguments and deferred until the browser is idle. Safari, Node, and other environments without requestIdleCallback

use a 1 ms timer fallback.

import { concurrencyLimit } from "@alsoftworks/temporize";

const upload = concurrencyLimit(async (file: File) => {
  const body = new FormData();
  body.append("file", file);
  const response = await fetch("/api/uploads", { method: "POST", body });
  if (!response.ok) throw new Error(`Upload failed: ${file.name}`);
  return response.json();
}, 3);

const uploaded = await Promise.all(files.map(upload));
console.log(upload.pending(), upload.queued());

At most three uploads start together. Excess calls wait in FIFO order. upload.cancel()

rejects queued calls, but deliberately lets uploads already in flight finish because the wrapped function may not be cancellable. Later calls can be queued normally; aborting the configured signal also rejects future calls.

React and Vue adapters are optional subpath exports. Installing @alsoftworks/temporize

does not install either framework, and importing the core package does not load adapter code. Add only the peer your application already uses.

npm install @alsoftworks/temporize react

Import hooks from @alsoftworks/temporize/react

:

import { useEffect, useState } from "react";
import { useDebouncedValue } from "@alsoftworks/temporize/react";

export function Search(): JSX.Element {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 300);

  useEffect(() => {
    if (!debouncedQuery) return;
    void fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
      placeholder="Search"
    />
  );
}

Use useThrottle

for high-frequency browser events. The hook keeps a stable function identity, invokes the latest callback closure, and cancels pending trailing work when the component unmounts.

import { useEffect, useState } from "react";
import { TemporizeAbortError } from "@alsoftworks/temporize";
import { useThrottle } from "@alsoftworks/temporize/react";

export function ScrollPosition(): JSX.Element {
  const [position, setPosition] = useState(0);
  const updatePosition = useThrottle((next: number) => {
    setPosition(next);
  }, 100);

  useEffect(() => {
    const handleScroll = () => {
      void updatePosition(window.scrollY).catch((error: unknown) => {
        if (!(error instanceof TemporizeAbortError)) throw error;
      });
    };
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, [updatePosition]);

  return <output>{position}</output>;
}

The React entry point exports useDebounce

, useThrottle

, useDebouncedValue

, and useRafThrottle

. Every function wrapper exposes the same cancel

, flush

, and pending

methods as its core equivalent; useRafThrottle

exposes cancel

.

npm install @alsoftworks/temporize vue

Import composables from @alsoftworks/temporize/vue

. useDebouncedRef

accepts a raw value, ref, computed ref, or getter and returns a normal writable ref.

<script setup lang="ts">
import { ref, watch } from "vue";
import { useDebouncedRef } from "@alsoftworks/temporize/vue";

const query = ref("");
const debouncedQuery = useDebouncedRef(query, 300);

watch(debouncedQuery, (value) => {
  if (!value) return;
  void fetch(`/api/search?q=${encodeURIComponent(value)}`);
});
</script>

<template>
  <input v-model="query" placeholder="Search" />
</template>

Use the Vue throttle composable for scroll handling. Pending work is cancelled through Vue's onUnmounted

lifecycle automatically.

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { TemporizeAbortError } from "@alsoftworks/temporize";
import { useThrottle } from "@alsoftworks/temporize/vue";

const position = ref(0);
const updatePosition = useThrottle((next: number) => {
  position.value = next;
}, 100);

const handleScroll = () => {
  void updatePosition(window.scrollY).catch((error: unknown) => {
    if (!(error instanceof TemporizeAbortError)) throw error;
  });
};

onMounted(() => window.addEventListener("scroll", handleScroll, { passive: true }));
onUnmounted(() => window.removeEventListener("scroll", handleScroll));
</script>

<template>
  <output>{{ position }}</output>
</template>

The Vue entry point exports useDebounce

, useThrottle

, useDebouncedRef

, and useRafThrottle

. React and Vue are optional peer dependencies, so the core library retains zero framework and runtime dependencies.

Returns (...args) => Promise<Awaited<R>>

with:

cancel(): void

clears the timer and rejects calls waiting to run with aTemporizeAbortError

. It does not stop work already invoked.flush(): Promise<Awaited<R>> | undefined

immediately runs pending trailing work and returns the shared result promise. It returns the most recent result when no work is pending, orundefined

before any invocation exists.pending(): boolean

reports whether trailing work is waiting to run.

DebounceOptions

:

Option Default Meaning
leading
false
Invoke at the beginning of the quiet window.
trailing
true
Invoke with the latest arguments at its end.
maxWait
none Force an invocation during a continuous call stream. Values below wait are clamped to wait .
signal
none Cancel pending work on abort and reject calls made after abort.

Negative and non-finitely falsy waits are treated as zero. A zero-wait trailing call still runs in a later timer task.

Returns the same promise function and lifecycle methods as debounce

. ThrottleOptions

:

Option Default Meaning
leading
true
Invoke at the beginning of the first window.
trailing
true
Invoke at the end with the latest suppressed call.
signal
none Cancel pending work on abort and reject calls made after abort.

Returns a void function with cancel(): void

. Repeated calls before a frame are coalesced and the latest arguments and this

value are used.

Returns the same lifecycle shape as debounce

. DebounceAsyncOptions

includes all DebounceOptions

plus:

Option Default Meaning
overlap
"queue"
Use "queue" , "drop" , or "cancel-previous" when a firing overlaps active work.

Calling .cancel()

rejects scheduled debounce work and overlap jobs that are queued but not started. It does not implicitly abort active work; the "cancel-previous"

policy aborts active work when its replacement starts.

Returns (...args) => Promise<Awaited<R>>

with:

cancel(): void

rejects every queued call and clears the drain timer.flush(): Promise<Awaited<R>> | undefined

immediately starts the next item.pending(): boolean

reports a queued item or scheduled drain.queued(): number

returns the number of calls not yet started.

ThrottlePromiseOptions

:

Option Default Meaning
leading
true
Start the first queued call immediately; when false, wait one window.
signal
none Cancel queued work on abort and reject calls made after abort.

Returns (...args) => Promise<R>

with:

cancel(): void

rejects all calls still queued in the current batch.flush(): Promise<R> | undefined

immediately invokes queued work and returns the shared result promise.pending(): boolean

reports whether the current batch contains calls.size(): number

returns the number of queued argument tuples.

BatchOptions

:

Option Default Meaning
maxSize
unlimited Fire immediately when the queue reaches this size. Values below 1 become 1 .
signal
none Cancel queued work on abort and reject calls made after abort.

The wrapped function receives Args[]

, preserving every call's complete argument tuple. It runs only once per batch, so all callers in that batch settle with the same result or error.

Returns an async function with the same inferred arguments and resolved result. The first successful attempt resolves the call. Exhaustion rejects with a TemporizeTimeoutError

whose attempts

and cause

expose the attempt count and last failure. Aborting rejects immediately with TemporizeAbortError

.

RetryOptions

:

Option Default Meaning
attempts
3
Maximum total invocations, including the first.
baseDelay
200
Delay in milliseconds after the first failure.
maxDelay
5000
Maximum delay between attempts.
factor
2
Exponential multiplier for each subsequent delay.
jitter
true
Randomize each delay between zero and its calculated value.
shouldRetry
retry every error Decide whether a failure and its one-based attempt number are retryable.
signal
none Abort an active wait and prevent future attempts.

Returns a void function with cancel(): void

. Calls before the idle callback runs are coalesced using the latest arguments and this

value.

IdleOptions

:

Option Default Meaning
timeout
browser default Native requestIdleCallback timeout in milliseconds.

When idle callbacks are unavailable, scheduling falls back to setTimeout(fn, 1)

.

Returns (...args) => Promise<R>

with:

cancel(): void

rejects queued calls without interrupting work already in flight; later calls remain usable.pending(): number

returns the number of running calls.queued(): number

returns the number of calls waiting for a slot.

Calls start in FIFO order as slots become available. max

must be a positive integer and invalid values throw a synchronous TypeError

. Passing { signal }

applies the same queued-only cancellation rule and preserves the signal's abort reason on TemporizeAbortError.reason

.

TemporizeAbortError

identifies cancellation by .cancel()

or an AbortSignal

. Its reason

and cause

preserve a supplied signal reason. TemporizeTimeoutError

identifies exhausted retries and exposes attempts

plus the last underlying error through cause

.

npm run build

emits minified ESM, CommonJS, and declarations in dist/

. The implementation is tree-shakeable (sideEffects: false

) and has no runtime dependencies. The core exports are designed to remain below 1 kB each after minification and gzip when consumed individually. Current tree-shaken ESM measurements with esbuild are: debounce

721 B, throttle

759 B, rafThrottle

236 B, throttlePromise

559 B, debounceAsync

997 B, batch

512 B, retry

662 B, idle

241 B, and concurrencyLimit

526 B. debounceAsync

is closest to the budget because its concurrency state machine and internal AbortController must cover all three overlap policies.

Use a bundle analyzer in the final application because shared helpers, module format wrappers, and bundler chunking affect attributed per-export sizes.

The optional adapters are measured as independent, minified ESM bundles with their framework peer externalized. The React entry is 1,169 B gzipped and the Vue entry is 1,089 B gzipped. Both include the core scheduling code they use and remain below the separate 1.5 kB adapter budget.

npm test
npm run typecheck
npm run lint
npm run build
npm run size
npm run bench

Use npm run build:core

or npm run test:core

when working only on the framework-free entry point. npm publish

runs the complete tests, strict type check, and all three builds automatically through prepublishOnly

.

React, Vue, jsdom, and their testing utilities are repository-only development dependencies. npm does not support installing only a named subset of one package's devDependencies

, so contributors running a normal npm install

receive the adapter test stack. Package consumers do not: React and Vue are optional peers, stay external in adapter builds, and never enter the core bundle.

See CONTRIBUTING.md for contribution requirements and CHANGELOG.md for release history.

The tinybench benchmark measures raw wrapper dispatch overhead against the standalone lodash.debounce

and lodash.throttle

packages. It intentionally does not claim behavioral equivalence: temporize allocates a real promise for every call, which is part of its contract and part of the measured cost.

MIT. See LICENSE. Third-party development and benchmark references are documented in THIRD_PARTY_NOTICES.md; they are not included in Temporize's runtime bundles.

── more in #developer-tools 4 stories · sorted by recency
── more on @alsoftworks 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-made-a-promise-awa…] indexed:0 read:13min 2026-08-02 ·