{"slug": "i-made-a-promise-aware-debounce-and-throttle-library-for-typescript", "title": "I made a Promise-aware debounce and throttle library for TypeScript", "summary": "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.", "body_md": "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.\n\n```\nnpm install @alsoftworks/temporize\n```\n\n| Capability | temporize | lodash.debounce / lodash.throttle |\n|---|---|---|\n| Inferred arguments and resolved return value | Full generic inference | Types available separately |\n| Per-call return | `Promise<Awaited<R>>` |\nLast synchronous result or `undefined` |\n| Async error propagation | Native promise rejection | Caller manages returned value |\n| Cancellation | `.cancel()` and `AbortSignal` |\n`.cancel()` |\n| Maximum debounce wait | Yes | Yes |\n| Promise queue rate limiter | `throttlePromise` |\nNo |\n| Async overlap policy | `debounceAsync` |\nNo |\n| Animation-frame throttling | Browser API plus Node fallback | No |\n| Multi-call argument batching | `batch` |\nNo |\n| Exponential-backoff retries | `retry` |\nNo |\n| Idle-period scheduling | `idle` with Safari/Node fallback |\nNo |\n| Concurrent promise limiting | `concurrencyLimit` with FIFO queue |\nNo |\n| Runtime dependencies | Zero | Zero for per-method packages |\n| Modules | ESM and CommonJS | CommonJS per-method packages |\n\ntemporize 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.\n\n``` js\nimport { debounce } from \"@alsoftworks/temporize\";\n\nconst search = debounce(\n  async (query: string) => {\n    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);\n    return response.json() as Promise<{ total: number }>;\n  },\n  250,\n  { maxWait: 1_000 },\n);\n\nconst result = await search(\"type inference\");\nconsole.log(result.total);\n\nsearch.pending();\nawait search.flush();\nsearch.cancel();\n```\n\nSeveral 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.\n\n``` js\nimport { throttle } from \"@alsoftworks/temporize\";\n\nconst savePosition = throttle(\n  (x: number, y: number) => ({ x, y, savedAt: Date.now() }),\n  100,\n  { leading: true, trailing: true },\n);\n\nconst saved = await savePosition(120, 80);\n```\n\nRegular throttling coalesces excess calls and uses the latest arguments for a trailing invocation.\n\n``` js\nimport { rafThrottle } from \"@alsoftworks/temporize\";\n\nconst updateLayout = rafThrottle((width: number) => {\n  document.documentElement.style.setProperty(\"--viewport-width\", `${width}px`);\n});\n\nwindow.addEventListener(\"resize\", () => updateLayout(window.innerWidth));\n\n// Discard a frame that has been requested but has not run.\nupdateLayout.cancel();\n```\n\nIn browsers, `rafThrottle`\n\nuses `requestAnimationFrame`\n\n. During SSR and in Node,\nit falls back to a 16 ms `setTimeout`\n\n, so importing and calling it is safe when\nanimation-frame globals do not exist.\n\n``` js\nimport { debounceAsync } from \"@alsoftworks/temporize\";\n\nconst loadUser = debounceAsync(\n  async (id: string, signal: AbortSignal) => {\n    const response = await fetch(`/api/users/${id}`, { signal });\n    return response.json() as Promise<{ id: string; name: string }>;\n  },\n  200,\n  { overlap: \"cancel-previous\" },\n);\n\n// The AbortSignal parameter is supplied internally and omitted from this call.\nconst user = await loadUser(\"user_123\");\n```\n\nThe overlap policies are:\n\n`\"queue\"`\n\n(default): keep each fired invocation and start it after active work settles.`\"drop\"`\n\n: do not start the overlapping invocation; its callers adopt the active invocation's promise.`\"cancel-previous\"`\n\n: 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.\n\nFor typed signal injection, declare a required `AbortSignal`\n\nas the wrapped\nfunction's final parameter. `debounceAsync`\n\nremoves that parameter from the\nreturned function's call signature. Cancellation is cooperative: an async\noperation must observe the signal to stop its in-flight work.\n\n``` js\nimport { throttlePromise } from \"@alsoftworks/temporize\";\n\nconst sendRequest = throttlePromise(\n  async (path: string) => fetch(path).then((response) => response.status),\n  100,\n);\n\nconst requests = [\n  sendRequest(\"/api/one\"),\n  sendRequest(\"/api/two\"),\n  sendRequest(\"/api/three\"),\n];\n\nconsole.log(sendRequest.queued());\nconsole.log(await Promise.all(requests));\n```\n\nEvery 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.\n\n``` js\nimport { batch } from \"@alsoftworks/temporize\";\n\nconst markNotificationsRead = batch(\n  async (calls: Array<[notificationId: string]>) => {\n    const ids = calls.map(([notificationId]) => notificationId);\n    const response = await fetch(\"/api/notifications/read\", {\n      method: \"POST\",\n      headers: { \"content-type\": \"application/json\" },\n      body: JSON.stringify({ ids }),\n    });\n    if (!response.ok) throw new Error(\"Could not mark notifications as read\");\n    return ids.length;\n  },\n  50,\n  { maxSize: 100 },\n);\n\nconst first = markNotificationsRead(\"notification_1\");\nconst second = markNotificationsRead(\"notification_2\");\n\n// Both promises resolve to 2 because both calls shared one batch invocation.\nconsole.log(await first, await second);\n```\n\nUnlike `debounce`\n\n, `batch`\n\nretains every argument tuple. The wrapped function\nruns once with the complete array, and every caller in that batch receives the\nsame result or error.\n\n``` js\nimport { retry } from \"@alsoftworks/temporize\";\n\nconst controller = new AbortController();\nconst fetchJson = retry(\n  async (url: string) => {\n    const response = await fetch(url, { signal: controller.signal });\n    if (!response.ok) throw new Error(`Request failed: ${response.status}`);\n    return response.json() as Promise<unknown>;\n  },\n  {\n    attempts: 4,\n    baseDelay: 250,\n    maxDelay: 2_000,\n    signal: controller.signal,\n  },\n);\n\nconst data = await fetchJson(\"/api/flaky-report\");\n```\n\nRetries use exponential backoff and randomized jitter by default. Supply\n`shouldRetry`\n\nwhen permanent errors should stop immediately.\n\n``` js\nimport { idle } from \"@alsoftworks/temporize\";\n\nconst recordAnalytics = idle(\n  (eventName: string, properties: object) => {\n    navigator.sendBeacon(\"/analytics\", JSON.stringify({ eventName, properties }));\n  },\n  { timeout: 2_000 },\n);\n\nrecordAnalytics(\"dashboard_viewed\", { source: \"navigation\" });\n```\n\nRapid calls are coalesced using the latest arguments and deferred until the\nbrowser is idle. Safari, Node, and other environments without\n`requestIdleCallback`\n\nuse a 1 ms timer fallback.\n\n``` js\nimport { concurrencyLimit } from \"@alsoftworks/temporize\";\n\nconst upload = concurrencyLimit(async (file: File) => {\n  const body = new FormData();\n  body.append(\"file\", file);\n  const response = await fetch(\"/api/uploads\", { method: \"POST\", body });\n  if (!response.ok) throw new Error(`Upload failed: ${file.name}`);\n  return response.json();\n}, 3);\n\nconst uploaded = await Promise.all(files.map(upload));\nconsole.log(upload.pending(), upload.queued());\n```\n\nAt most three uploads start together. Excess calls wait in FIFO order.\n`upload.cancel()`\n\nrejects queued calls, but deliberately lets uploads already in\nflight finish because the wrapped function may not be cancellable. Later calls\ncan be queued normally; aborting the configured signal also rejects future calls.\n\nReact and Vue adapters are optional subpath exports. Installing\n`@alsoftworks/temporize`\n\ndoes not install either framework, and importing the core package does not load\nadapter code. Add only the peer your application already uses.\n\n```\nnpm install @alsoftworks/temporize react\n```\n\nImport hooks from `@alsoftworks/temporize/react`\n\n:\n\n``` js\nimport { useEffect, useState } from \"react\";\nimport { useDebouncedValue } from \"@alsoftworks/temporize/react\";\n\nexport function Search(): JSX.Element {\n  const [query, setQuery] = useState(\"\");\n  const debouncedQuery = useDebouncedValue(query, 300);\n\n  useEffect(() => {\n    if (!debouncedQuery) return;\n    void fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);\n  }, [debouncedQuery]);\n\n  return (\n    <input\n      value={query}\n      onChange={(event) => setQuery(event.target.value)}\n      placeholder=\"Search\"\n    />\n  );\n}\n```\n\nUse `useThrottle`\n\nfor high-frequency browser events. The hook keeps a stable\nfunction identity, invokes the latest callback closure, and cancels pending\ntrailing work when the component unmounts.\n\n``` js\nimport { useEffect, useState } from \"react\";\nimport { TemporizeAbortError } from \"@alsoftworks/temporize\";\nimport { useThrottle } from \"@alsoftworks/temporize/react\";\n\nexport function ScrollPosition(): JSX.Element {\n  const [position, setPosition] = useState(0);\n  const updatePosition = useThrottle((next: number) => {\n    setPosition(next);\n  }, 100);\n\n  useEffect(() => {\n    const handleScroll = () => {\n      void updatePosition(window.scrollY).catch((error: unknown) => {\n        if (!(error instanceof TemporizeAbortError)) throw error;\n      });\n    };\n    window.addEventListener(\"scroll\", handleScroll, { passive: true });\n    return () => window.removeEventListener(\"scroll\", handleScroll);\n  }, [updatePosition]);\n\n  return <output>{position}</output>;\n}\n```\n\nThe React entry point exports `useDebounce`\n\n, `useThrottle`\n\n,\n`useDebouncedValue`\n\n, and `useRafThrottle`\n\n. Every function wrapper exposes the\nsame `cancel`\n\n, `flush`\n\n, and `pending`\n\nmethods as its core equivalent;\n`useRafThrottle`\n\nexposes `cancel`\n\n.\n\n```\nnpm install @alsoftworks/temporize vue\n```\n\nImport composables from `@alsoftworks/temporize/vue`\n\n. `useDebouncedRef`\n\naccepts\na raw value, ref, computed ref, or getter and returns a normal writable ref.\n\n``` js\n<script setup lang=\"ts\">\nimport { ref, watch } from \"vue\";\nimport { useDebouncedRef } from \"@alsoftworks/temporize/vue\";\n\nconst query = ref(\"\");\nconst debouncedQuery = useDebouncedRef(query, 300);\n\nwatch(debouncedQuery, (value) => {\n  if (!value) return;\n  void fetch(`/api/search?q=${encodeURIComponent(value)}`);\n});\n</script>\n\n<template>\n  <input v-model=\"query\" placeholder=\"Search\" />\n</template>\n```\n\nUse the Vue throttle composable for scroll handling. Pending work is cancelled\nthrough Vue's `onUnmounted`\n\nlifecycle automatically.\n\n``` js\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, ref } from \"vue\";\nimport { TemporizeAbortError } from \"@alsoftworks/temporize\";\nimport { useThrottle } from \"@alsoftworks/temporize/vue\";\n\nconst position = ref(0);\nconst updatePosition = useThrottle((next: number) => {\n  position.value = next;\n}, 100);\n\nconst handleScroll = () => {\n  void updatePosition(window.scrollY).catch((error: unknown) => {\n    if (!(error instanceof TemporizeAbortError)) throw error;\n  });\n};\n\nonMounted(() => window.addEventListener(\"scroll\", handleScroll, { passive: true }));\nonUnmounted(() => window.removeEventListener(\"scroll\", handleScroll));\n</script>\n\n<template>\n  <output>{{ position }}</output>\n</template>\n```\n\nThe Vue entry point exports `useDebounce`\n\n, `useThrottle`\n\n, `useDebouncedRef`\n\n,\nand `useRafThrottle`\n\n. React and Vue are optional peer dependencies, so the core\nlibrary retains zero framework and runtime dependencies.\n\nReturns `(...args) => Promise<Awaited<R>>`\n\nwith:\n\n`cancel(): void`\n\nclears the timer and rejects calls waiting to run with a`TemporizeAbortError`\n\n. It does not stop work already invoked.`flush(): Promise<Awaited<R>> | undefined`\n\nimmediately runs pending trailing work and returns the shared result promise. It returns the most recent result when no work is pending, or`undefined`\n\nbefore any invocation exists.`pending(): boolean`\n\nreports whether trailing work is waiting to run.\n\n`DebounceOptions`\n\n:\n\n| Option | Default | Meaning |\n|---|---|---|\n`leading` |\n`false` |\nInvoke at the beginning of the quiet window. |\n`trailing` |\n`true` |\nInvoke with the latest arguments at its end. |\n`maxWait` |\nnone | Force an invocation during a continuous call stream. Values below `wait` are clamped to `wait` . |\n`signal` |\nnone | Cancel pending work on abort and reject calls made after abort. |\n\nNegative and non-finitely falsy waits are treated as zero. A zero-wait trailing call still runs in a later timer task.\n\nReturns the same promise function and lifecycle methods as `debounce`\n\n.\n`ThrottleOptions`\n\n:\n\n| Option | Default | Meaning |\n|---|---|---|\n`leading` |\n`true` |\nInvoke at the beginning of the first window. |\n`trailing` |\n`true` |\nInvoke at the end with the latest suppressed call. |\n`signal` |\nnone | Cancel pending work on abort and reject calls made after abort. |\n\nReturns a void function with `cancel(): void`\n\n. Repeated calls before a frame are\ncoalesced and the latest arguments and `this`\n\nvalue are used.\n\nReturns the same lifecycle shape as `debounce`\n\n. `DebounceAsyncOptions`\n\nincludes\nall `DebounceOptions`\n\nplus:\n\n| Option | Default | Meaning |\n|---|---|---|\n`overlap` |\n`\"queue\"` |\nUse `\"queue\"` , `\"drop\"` , or `\"cancel-previous\"` when a firing overlaps active work. |\n\nCalling `.cancel()`\n\nrejects scheduled debounce work and overlap jobs that are\nqueued but not started. It does not implicitly abort active work; the\n`\"cancel-previous\"`\n\npolicy aborts active work when its replacement starts.\n\nReturns `(...args) => Promise<Awaited<R>>`\n\nwith:\n\n`cancel(): void`\n\nrejects every queued call and clears the drain timer.`flush(): Promise<Awaited<R>> | undefined`\n\nimmediately starts the next item.`pending(): boolean`\n\nreports a queued item or scheduled drain.`queued(): number`\n\nreturns the number of calls not yet started.\n\n`ThrottlePromiseOptions`\n\n:\n\n| Option | Default | Meaning |\n|---|---|---|\n`leading` |\n`true` |\nStart the first queued call immediately; when false, wait one window. |\n`signal` |\nnone | Cancel queued work on abort and reject calls made after abort. |\n\nReturns `(...args) => Promise<R>`\n\nwith:\n\n`cancel(): void`\n\nrejects all calls still queued in the current batch.`flush(): Promise<R> | undefined`\n\nimmediately invokes queued work and returns the shared result promise.`pending(): boolean`\n\nreports whether the current batch contains calls.`size(): number`\n\nreturns the number of queued argument tuples.\n\n`BatchOptions`\n\n:\n\n| Option | Default | Meaning |\n|---|---|---|\n`maxSize` |\nunlimited | Fire immediately when the queue reaches this size. Values below `1` become `1` . |\n`signal` |\nnone | Cancel queued work on abort and reject calls made after abort. |\n\nThe wrapped function receives `Args[]`\n\n, preserving every call's complete\nargument tuple. It runs only once per batch, so all callers in that batch settle\nwith the same result or error.\n\nReturns an async function with the same inferred arguments and resolved result.\nThe first successful attempt resolves the call. Exhaustion rejects with a\n`TemporizeTimeoutError`\n\nwhose `attempts`\n\nand `cause`\n\nexpose the attempt count\nand last failure. Aborting rejects immediately with `TemporizeAbortError`\n\n.\n\n`RetryOptions`\n\n:\n\n| Option | Default | Meaning |\n|---|---|---|\n`attempts` |\n`3` |\nMaximum total invocations, including the first. |\n`baseDelay` |\n`200` |\nDelay in milliseconds after the first failure. |\n`maxDelay` |\n`5000` |\nMaximum delay between attempts. |\n`factor` |\n`2` |\nExponential multiplier for each subsequent delay. |\n`jitter` |\n`true` |\nRandomize each delay between zero and its calculated value. |\n`shouldRetry` |\nretry every error | Decide whether a failure and its one-based attempt number are retryable. |\n`signal` |\nnone | Abort an active wait and prevent future attempts. |\n\nReturns a void function with `cancel(): void`\n\n. Calls before the idle callback\nruns are coalesced using the latest arguments and `this`\n\nvalue.\n\n`IdleOptions`\n\n:\n\n| Option | Default | Meaning |\n|---|---|---|\n`timeout` |\nbrowser default | Native `requestIdleCallback` timeout in milliseconds. |\n\nWhen idle callbacks are unavailable, scheduling falls back to `setTimeout(fn, 1)`\n\n.\n\nReturns `(...args) => Promise<R>`\n\nwith:\n\n`cancel(): void`\n\nrejects queued calls without interrupting work already in flight; later calls remain usable.`pending(): number`\n\nreturns the number of running calls.`queued(): number`\n\nreturns the number of calls waiting for a slot.\n\nCalls start in FIFO order as slots become available. `max`\n\nmust be a positive\ninteger and invalid values throw a synchronous `TypeError`\n\n. Passing\n`{ signal }`\n\napplies the same queued-only cancellation rule and preserves the\nsignal's abort reason on `TemporizeAbortError.reason`\n\n.\n\n`TemporizeAbortError`\n\nidentifies cancellation by `.cancel()`\n\nor an\n`AbortSignal`\n\n. Its `reason`\n\nand `cause`\n\npreserve a supplied signal reason.\n`TemporizeTimeoutError`\n\nidentifies exhausted retries and exposes `attempts`\n\nplus the last underlying error through `cause`\n\n.\n\n`npm run build`\n\nemits minified ESM, CommonJS, and declarations in `dist/`\n\n.\nThe implementation is tree-shakeable (`sideEffects: false`\n\n) and has no runtime\ndependencies. The core exports are designed to remain below 1 kB each after\nminification and gzip when consumed individually. Current tree-shaken ESM\nmeasurements with esbuild are: `debounce`\n\n721 B, `throttle`\n\n759 B,\n`rafThrottle`\n\n236 B, `throttlePromise`\n\n559 B, `debounceAsync`\n\n997 B, `batch`\n\n512 B, `retry`\n\n662 B, `idle`\n\n241 B, and `concurrencyLimit`\n\n526 B.\n`debounceAsync`\n\nis closest to the budget because its concurrency state machine\nand internal AbortController must cover all three overlap policies.\n\nUse a bundle analyzer in the final application because shared helpers, module format wrappers, and bundler chunking affect attributed per-export sizes.\n\nThe 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.\n\n```\nnpm test\nnpm run typecheck\nnpm run lint\nnpm run build\nnpm run size\nnpm run bench\n```\n\nUse `npm run build:core`\n\nor `npm run test:core`\n\nwhen working only on the\nframework-free entry point. `npm publish`\n\nruns the complete tests, strict type\ncheck, and all three builds automatically through `prepublishOnly`\n\n.\n\nReact, Vue, jsdom, and their testing utilities are repository-only development\ndependencies. npm does not support installing only a named subset of one\npackage's `devDependencies`\n\n, so contributors running a normal `npm install`\n\nreceive the adapter test stack. Package consumers do not: React and Vue are\noptional peers, stay external in adapter builds, and never enter the core\nbundle.\n\nSee [CONTRIBUTING.md](/nyvexis1/temporize/blob/main/CONTRIBUTING.md) for contribution requirements and\n[CHANGELOG.md](/nyvexis1/temporize/blob/main/CHANGELOG.md) for release history.\n\nThe tinybench benchmark measures raw wrapper dispatch overhead against the\nstandalone `lodash.debounce`\n\nand `lodash.throttle`\n\npackages. It intentionally\ndoes not claim behavioral equivalence: temporize allocates a real promise for\nevery call, which is part of its contract and part of the measured cost.\n\nMIT. See [LICENSE](/nyvexis1/temporize/blob/main/LICENSE). Third-party development and benchmark references\nare documented in [THIRD_PARTY_NOTICES.md](/nyvexis1/temporize/blob/main/THIRD_PARTY_NOTICES.md); they are not\nincluded in Temporize's runtime bundles.", "url": "https://wpnews.pro/news/i-made-a-promise-aware-debounce-and-throttle-library-for-typescript", "canonical_source": "https://github.com/nyvexis1/temporize", "published_at": "2026-08-02 11:07:56+00:00", "updated_at": "2026-08-02 11:23:23.080143+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Alsoftworks", "temporize", "lodash.debounce", "lodash.throttle"], "alternates": {"html": "https://wpnews.pro/news/i-made-a-promise-aware-debounce-and-throttle-library-for-typescript", "markdown": "https://wpnews.pro/news/i-made-a-promise-aware-debounce-and-throttle-library-for-typescript.md", "text": "https://wpnews.pro/news/i-made-a-promise-aware-debounce-and-throttle-library-for-typescript.txt", "jsonld": "https://wpnews.pro/news/i-made-a-promise-aware-debounce-and-throttle-library-for-typescript.jsonld"}}