Repository: remeda/remeda
Pull request #848 (2024-09-06 14:15:56)

feat: add `funnel`, an execution timing utility
A replacement and extension of the existing `debounce` utility that provides more control over timing parameters, fixes throttling bugs, allows collecting parameters to allow more flexibility in processing, and removes direct management of return value state.

Fixes: #822

!Important: This massive PR requires a lot of attention. Despite its size, most of this PR was done manually (not via copy-paste or generated code), test-by-test. Reviewing this test by test doesn't make sense, but at least all the content blocks (comments, docs) and the implementation should be reviewed.

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `docs/src/content/mapping` for both Lodash and Ramda.

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions, and changes to a function's type that would impact users.
- `fix`: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/remeda/remeda/pr/848/builds/539081) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit fdf25bcce919762d2fc76b410b82ce47e5b18e47:

|Sandbox| Source |
|--|--|
|[remeda-example-vanilla](https://codesandbox.io/s/remeda-example-vanilla-54ths3)| Configuration |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/848?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
Attention: Patch coverage is `1.21951%` with `81 lines` in your changes missing coverage. Please review.
> Project coverage is 99.24%. Comparing base [(`a90e69d`)](https://app.codecov.io/gh/remeda/remeda/commit/a90e69d730f5242ecb66c883b32d3457f3c1acd8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`7c4ca8d`)](https://app.codecov.io/gh/remeda/remeda/commit/7c4ca8d4f8d5c7693e609818c728a94aaf2f0f9a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

| [Files with missing lines](https://app.codecov.io/gh/remeda/remeda/pull/848?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) | Patch % | Lines |
|---|---|---|
| [src/batcher.ts](https://app.codecov.io/gh/remeda/remeda/pull/848?src=pr&el=tree&filepath=src%2Fbatcher.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda#diff-c3JjL2JhdGNoZXIudHM=) | 1.21% | [81 Missing :warning: ](https://app.codecov.io/gh/remeda/remeda/pull/848?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #848      +/-   ##
===========================================
- Coverage   100.00%   99.24%   -0.76%     
===========================================
  Files          313      314       +1     
  Lines        10667    10749      +82     
  Branches      2609     2610       +1     
===========================================
+ Hits         10667    10668       +1     
- Misses           0       81      +81     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/848?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

cjquines: wait, why replace?
eranhirsch: It does more, debounce is a use-case of this. It's like compact vs filter(isTruthy) 
cjquines: two thoughts:

1. when i've used something like `batcher` in the past, i've wanted to split up the return values back to the individual calls, like what e.g. [batshit does](https://www.npmjs.com/package/@yornaath/batshit). with this we only get one big value for the whole batch

2. i think it's conceptually different enough, even if `debounce` is a special case. the name `batcher` suggests we do something on the whole batch of calls in between invokes, but with `debounce` and `throttle`, we're only doing something using the very last call.

    with `compact`, you can read `filter(isTruthy)` and it's clear that these are the same; but `debounce` and `batcher({ prepare: (prev, ...params) => params })` read quite different to me. (compare the other ones we've removed: `countBy` with `filter.length` or `flatMapToObj` with `fromEntries(flatMap)`, which are simpler compositions.)
eranhirsch: Your concerns are on point. The PR isn't done yet and even the name is still not final. 

The aim is to create a doc with recipes on top of this with all the combos you are thinking about.

This is utility is very different from others we offer so I'm still considering how best to handle it. The last thing i want is to maintain several runtime "utility that has timers", i want one and have it be the definitive one. If we feel that a recipe doc is not enough we should consider helper utils on top of it, but even then, that would the first in the library and thus a slippery slope... 
cjquines: decent number of our problems would be solved if we had a `remeda-contrib` 😅 
cjquines: hey, probably won't get to reviewing this weekend, but wanted to raise a question real quick—i know we can derive `debounce` or `throttle` from `funnel`, but do we really want to not include it because it is derivable? the number of settings you have to pass in are quite big

if we don't include `debounce` or `throttle`:

* have to make decisions about which implementation of `debounce` and `throttle` to use
    * if we envision remeda to eventually replace lodash (which is part of my personal vision) then we might as well pick the lodash impls
* we can get rid of some of the text in the migrations pages
* much simpler for library consumers
    * by far the most common use case won't need batching
    * also, do i really want to think about which flavor of `debounce` i care about

(i know these arguments also apply for some other functions, but _particularly_ the complexity of `debounce` and `throttle` make these tradeoffs apparent, especially since it's not just a one-line replacement)

non-arguments (imo):

* would increase the number of functions we export, which means increased surface area / harder to learn stuff
    * we already export a lot of functions anyway. plus, `debounce` and `throttle` are much easier to grok than `funnel`
    * it also wouldn't increase the bundle size compared to consumers copying our implementation, because of strong tree-shaking
* no separate function = less explicit tests needed
    * i don't think we actually need to test derived functions so thoroughly. like, we don't need to have type tests for them i think? because they'd be redundant
    * and for `debounce` and `throttle` we're already including a bunch of tests anyway
eranhirsch: Concretely, the only difference between funnel and debounce is **the reducer callback**, as the other params are also part of debounce:

```ts
_.debounce(callback, burstCoolDownMs);
_.throttle(callback, delayMs);

R.funnel(reducer, callback, { burstCoolDownMs });
R.funnel(reducer, callback, { delayMs });
```

I agree this is a real issue; there's no easy way to "plug" the reducer without knowing precisely what you are doing. For the two most common use cases, the resulting code isn't as trivial as I would want it to be:

```ts
// 1. No args.
R.funnel(constant(anything except `undefined`), ...);

// 2. Last args (lodash-style)
R.funnel((_, ...args) => args, ...); 

// 3. First args
R.funnel((prev, ...args) => prev ?? args, ...);

// 4. All args
R.funnel((prev, ...args) => [...(prev ?? []), args], ...);
``` 

The first case is, at the same time, the most common and least obvious to do in Remeda; we can consider allowing an `undefined` reducer to fall back to it. For the rest, I couldn't come up with a way where a remeda utility makes it better (e.g., `identity`, `concat`, etc...). I'll be happy to get ideas for other solutions, too.

---

## But more broadly speaking...

I think Lodash's implementation is kinda... bad. That brings me to something I've already said in the past, which is that we don't aim to be a drop-in replacement for Lodash (e.g., `isEmpty(undefined)`) but want to make migrating as easy as possible (so missing functionality is something we care about, but not different APIs).

People have this wrong assumption about Lodash being this very high-quality, well designed and planned, highly optimized for perf, and well-tested library, but that's just not what Lodash is. Lodash is just an aggregation of loose ends collected from people's "util" libraries over time, mostly around 2012-2016, and at a time where EMCAScript was just not a good standard to rely on, so people had to make guesses on how APIs should behave.

Taking debounce as an example, Lodash put a lot of code to support managing the "cachedValue" spec, but very few examples in the wild ever use that value, and its implementation assumes the lack of modern async/promise constructs that just work differently. Additionally, Lodash makes assumptions about what argument you want to use with your callback, which leaves anyone with a different requirement stuck without an implementation; similarly, examples and blog posts rarely use the argument, so they hide the problems.

So what should we offer? A debounce with a last-arg spec? A debounce with a cached-value spec? Both? None? Aren't we doing people a disservice by bundling logic they don't need just because someone might have asked for it 8 years ago?

The more I worked on this PR, the more I liked your idea of an adjacent library (remedax? rex?) or even just an adjacent import path, which **does** offer quick composite solutions on top of core utility functions but with a ton of caveats about the amount of support we will offer, how thorough it is tested, and any performance implications by possibly doing redundant stuff or skipping our lazy evaluation mechanism. This would allow us to offer the whole range of mapped utility functions we currently reject to adding: `reject`, `compact`, `pluck`, etc... 

Another approach is to provide an adapter layer for lodash/ramda that would allow you to replace imports from lodash with imports from a "remeda/lodash" path which will provide adapters that makes it possible to migrate to Remeda without any changes to your code (with the caveat that this doesn't provide easy utilities for people who are **not** coming from Lodash).
```ts
import _ from "remeda/lodash";
```

Two final approaches are to provide snippets: either like shadcn, where we have a section of the docs site for them; or as test files, where they are coupled with the code and could show usage examples via test suites.

The latter approach was used in this PR as it required the fewest changes to our docs infra. If this approach wins, we can consider creating a different folder for it (so we can remove it from coverage reports), and we should definitely add more reference implementations!
eranhirsch: Another way to view this is that it makes sense for Remeda to be a Lodash replacement but not a Lodash **v4** replacement; that is, it's OK for us to make breaking changes to the Lodash APIs as long as people have a mostly trivial way to migrate from one "version" to the next (in other words Remeda should be seen as Lodash v5, not a replacement for Lodash v4).
eranhirsch: I redesigned the solution so that the reducer is optional and part of the options object, making more straightforward cases with no args easier to configure. This doesn't yet solve the issue of how to make the reducer functions easier to read and write:

```ts
const debounced = _.debounce(callback, burstCoolDownMs);
debounced();

const debounced = R.funnel(callback, { burstCoolDownMs });
debounced.call();
```

```ts
const throttled = _.throttle(callback, delayMs);
throttled();

const throttled = R.funnel(callback, { delayMs });
throttled.call();
```
eranhirsch: @cjquines, where are we on this, it's still the top missing feature for people coming from Lodash
cjquines: i'll try to take a look later this week, during thanksgiving
eranhirsch: Your biggest concerns seem to be around the following:

1. Naming, which I agree with and would love to reach an agreement on. I'm OK with using debounceMs and throttleMs as those would be very easy to grok for people coming from Lodash, but fear that people not familiar with Lodash would find them non-descriptive enough to understand.

2. The fact that debounce and throttle live within the same function; causing a mish-mash of terms and flexibilities that could be split into more concise utilities. I understand why the current API might be confusing, but I assumed that people would only use the specific parts of the options object that answer their need, just like react-query provides a huge Options object to useQuery, but people aren't expected to understand all of it to use it. One of the things I liked about having both timers is that it allows you to throttle and debounce at the same time! something that Lodash doesn't provide (you can only "throttle" by using maxWait === wait). e.g., in our implementation, you can build a funnel that:
a. Is never invoked more than once per second (delay)
b. Is prevented from being called too frequently (100ms burst)
c. Is at least called once every 5 seconds, even if during a burst (max burst duration). 
cjquines: with regards to naming, i'm currently thinking something like

```ts
function funnel(callback: Function, options: {
  reducer: Function;
  callAt: "end" | "start" | "both";
  idleThresholdMs: number;
  groupLimitMs: number;
  callIntervalMs: number;
});
```

rationale:

* `callAt`: while we use the word "invoke" a lot, i don't think we should use it in the param... we use "call" more. the js name is `Function.prototype.call` too

* `idleThresholdMs`: the main use of debounce, at least in the frontend, is to detect when the user is idle. we debounce calls to window resizing and typing, because we want to wait for the user to idle before doing anything; compare this to scroll handlers or animations, where we don't want the user to idle before we do things. i think the "idle" framing is better than the "burst" framing in this case. i don't know how i feel about "threshold", but it feels like the best word

* `groupLimitMs`: this is the one i feel least sure about. i think the advantage of "group" over "burst" is when you write docs. "limit" is short and feels better than e.g. "timeout" or "maxDuration"

* `callIntervalMs`: "interval" is specifically chosen to match `setInterval`

for docs, imagine something like:

> Creates a funnel that groups consecutive calls to `callback` according to a timing policy.
>
> When the funnel is first called, it starts a new group. Subsequent calls are part of this group. The group ends when:
> * `callIntervalMs` has passed since the last `callback` call, _and_
>   * `idleThresholdMs` has passed since the last funnel call, _or_
>   * `groupLimitMs` has passed since the start of the group.
>
> The `callback` can be called at the start of each group, at the end of each group, or both. A `reducer` can combine arguments before passing them to `callback`.

plus some of the explanation that's currently there
eranhirsch: re *call* vs *invoke*: my thinking is that call refers to the user calling the `.call(...)` function of the funnel; this is where the business logic is **actively** interacting with the funnel. `invoke` on the other hand is when the funnel is triggered, this is an internal **passive** event that the business logic isn't aware of.
cjquines: hm. right now i don't think the words "call" and "invoke" give that meaning, they're kinda the same word. i'm currently leaning toward "funnel call" and "callback call"
eranhirsch: Hey @cjquines , I did another take on the things that you surfaced last time:
1. Changed timer param names (minQuietPeriodMs, maxGapMs, and minGapMs), yet the debouncing timer is still referred to as a "burst" in the docs.
2. Prevent certain combinations of options at the type level.
3. Default minQuietPeriodMs to 0 when no timers are configured.

Let's try and get this thing shipped. We can discuss changes in the future in a major version bump (which we can plan for this june 2025 once Node 18 reaches EOL). 
eranhirsch: OK... I still think I can make it even clearer...
eranhirsch: While working on the previous revision I noticed that it's wasn't intuitive to define the batcher with a non 0 time window. I made 2 changes: 1. rename the option so that it reflects the whole burst window. 2. allow calling the funnel with *just* the `maxBurstDurationMs` param and default the `minQuietPeriodMs` param to it when it isn't defined too (similar to what lodash does).

This now allows many use-cases to be defined with simple params:
```ts
const debounced = funnel(func, { minQuietPeriodMs: 100 });
const throttled = funnel(func, { minGapMs: 100, triggerAt: "both" });
const batched = funnel(func, { reducer: (acc, params) => [...(acc ?? []), params] });
const timeBatched = funnel(func, { reducer: ..., maxBurstDurationMs: 100 });
```


Patch:
diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
index ad87ba0..24634c7 100644
--- a/.github/workflows/codecov.yml
+++ b/.github/workflows/codecov.yml
@@ -22,7 +22,7 @@ jobs:
         uses: bahmutov/npm-install@v1
 
       - name: ☔ Collect coverage
-        run: npm run coverage
+        run: npm run test:coverage
 
       - name: 🗺️ Upload coverage
         uses: codecov/codecov-action@v5
diff --git a/docs/src/content/mapping/lodash/debounce.md b/docs/src/content/mapping/lodash/debounce.md
index 8cc8d6b..6515170 100644
--- a/docs/src/content/mapping/lodash/debounce.md
+++ b/docs/src/content/mapping/lodash/debounce.md
@@ -1,4 +1,137 @@
 ---
 category: Function
-remeda: debounce
+remeda: funnel
 ---
+
+- `debounce` can be implemented using the `funnel` utility. A reference
+  implementation is provided below, and a more expanded version with inline
+  documentation and tests is available in the test file [`funnel.lodash-debounce.test.ts`](https://github.com/remeda/remeda/blob/main/src/funnel.lodash-debounce.test.ts).
+
+- A more complete reference implementation that also maintains Lodash's
+  capability to store the callback's return value is available below, and in [`funnel.lodash-debounce-with-cached-value.test.ts`](https://github.com/remeda/remeda/blob/main/src/funnel.lodash-debounce-with-cached-value.test.ts).
+
+- These implementations can be copied as-is into your project, but might contain
+  redundant parts which are not relevant for your specific use cases. By
+  inlining only the parts you need you can take advantage of capabilities not
+  available in Lodash.
+
+### Reference
+
+```ts
+function debounce<F extends (...args: any) => void>(
+  func: F,
+  wait = 0,
+  {
+    leading = false,
+    trailing = true,
+    maxWait,
+  }: {
+    readonly leading?: boolean;
+    readonly trailing?: boolean;
+    readonly maxWait?: number;
+  } = {},
+) {
+  const {
+    call,
+    isIdle: _isIdle,
+    ...rest
+  } = funnel(
+    () => {
+      if (leading || trailing) {
+        func();
+      }
+    },
+    {
+      burstCoolDownMs: wait,
+      ...(maxWait !== undefined && { maxBurstDurationMs: maxWait }),
+      invokedAt: trailing ? (leading ? "both" : "end") : "start",
+    },
+  );
+  return Object.assign(call, rest);
+}
+```
+
+### With call arguments
+
+```ts
+function debounce<F extends (...args: any) => void>(
+  func: F,
+  wait = 0,
+  {
+    leading = false,
+    trailing = true,
+    maxWait,
+  }: {
+    readonly leading?: boolean;
+    readonly trailing?: boolean;
+    readonly maxWait?: number;
+  } = {},
+) {
+  const {
+    call,
+    isIdle: _isIdle,
+    ...rest
+  } = funnel(
+    (args: Parameters<F>) => {
+      if (leading || trailing) {
+        func(...args);
+      }
+    },
+    {
+      reducer: (_, ...args: Parameters<F>) => args,
+      burstCoolDownMs: wait,
+      ...(maxWait !== undefined && { maxBurstDurationMs: maxWait }),
+      invokedAt: trailing ? (leading ? "both" : "end") : "start",
+    },
+  );
+  return Object.assign(call, rest);
+}
+```
+
+### With cached value
+
+```ts
+function debounce<F extends (...args: any) => any>(
+  func: F,
+  wait = 0,
+  {
+    leading = false,
+    trailing = true,
+    maxWait,
+  }: {
+    readonly leading?: boolean;
+    readonly trailing?: boolean;
+    readonly maxWait?: number;
+  } = {},
+) {
+  let cachedValue: ReturnType<F> | undefined;
+
+  const { call, flush, cancel } = funnel(
+    (args: Parameters<F>) => {
+      if (leading || trailing) {
+        cachedValue = func(...args) as ReturnType<F>;
+      }
+    },
+    {
+      reducer: (_, ...args: Parameters<F>) => args,
+      burstCoolDownMs: wait,
+      ...(maxWait !== undefined && { maxBurstDurationMs: maxWait }),
+      invokedAt: trailing ? (leading ? "both" : "end") : "start",
+    },
+  );
+  return Object.assign(
+    (...args: Parameters<F>) => {
+      call(...args);
+      return cachedValue;
+    },
+    {
+      flush: () => {
+        flush();
+        return cachedValue;
+      },
+
+      cancel,
+    },
+  );
+}
+```
diff --git a/docs/src/content/mapping/lodash/throttle.md b/docs/src/content/mapping/lodash/throttle.md
new file mode 100644
index 0000000..5ca30e4
--- /dev/null
+++ b/docs/src/content/mapping/lodash/throttle.md
@@ -0,0 +1,123 @@
+---
+category: Function
+remeda: funnel
+---
+
+- `throttle` can be implemented using the `funnel` utility. A reference
+  implementation is provided below, and a more expanded version with inline
+  documentation and tests is available in the test file [`funnel.lodash-throttle.test.ts`](https://github.com/remeda/remeda/blob/main/src/funnel.lodash-throttle.test.ts).
+
+- A more complete reference implementation that also maintains Lodash's
+  capability to store the callback's return value is available below, and in [`funnel.lodash-throttle-with-cached-value.test.ts`](https://github.com/remeda/remeda/blob/main/src/funnel.lodash-throttle-with-cached-value.test.ts).
+
+- These implementations can be copied as-is into your project, but might contain
+  redundant parts which are not relevant for your specific use cases. By
+  inlining only the parts you need you can take advantage of capabilities not
+  available in Lodash.
+
+### Reference
+
+```ts
+function throttle<F extends (...args: any) => void>(
+  func: F,
+  wait = 0,
+  {
+    leading = true,
+    trailing = true,
+  }: { readonly leading?: boolean; readonly trailing?: boolean } = {},
+) {
+  const {
+    call,
+    isIdle: _isIdle,
+    ...rest
+  } = funnel(
+    () => {
+      if (leading || trailing) {
+        func();
+      }
+    },
+    {
+      burstCoolDownMs: wait,
+      maxBurstDurationMs: wait,
+      invokedAt: trailing ? (leading ? "both" : "end") : "start",
+    },
+  );
+  return Object.assign(call, rest);
+}
+```
+
+### With call arguments
+
+```ts
+function throttle<F extends (...args: any) => void>(
+  func: F,
+  wait = 0,
+  {
+    leading = true,
+    trailing = true,
+  }: { readonly leading?: boolean; readonly trailing?: boolean } = {},
+) {
+  const {
+    call,
+    isIdle: _isIdle,
+    ...rest
+  } = funnel(
+    (args: Parameters<F>) => {
+      if (leading || trailing) {
+        func(...args);
+      }
+    },
+    {
+      reducer: (_, ...args: Parameters<F>) => args,
+      burstCoolDownMs: wait,
+      maxBurstDurationMs: wait,
+      invokedAt: trailing ? (leading ? "both" : "end") : "start",
+    },
+  );
+  return Object.assign(call, rest);
+}
+```
+
+### With cached value
+
+```ts
+function throttle<F extends (...args: any) => any>(
+  func: F,
+  wait = 0,
+  {
+    leading = true,
+    trailing = true,
+  }: { readonly leading?: boolean; readonly trailing?: boolean } = {},
+) {
+  let cachedValue: ReturnType<F> | undefined;
+
+  const { call, flush, cancel } = funnel(
+    (args: Parameters<F>) => {
+      if (leading || trailing) {
+        cachedValue = func(...args) as ReturnType<F>;
+      }
+    },
+    {
+      reducer: (_, ...args: Parameters<F>) => args,
+      burstCoolDownMs: wait,
+      maxBurstDurationMs: wait,
+      invokedAt: trailing ? (leading ? "both" : "end") : "start",
+    },
+  );
+
+  return Object.assign(
+    (...args: Parameters<F>) => {
+      call(...args);
+      return cachedValue;
+    },
+    {
+      flush: () => {
+        flush();
+        return cachedValue;
+      },
+
+      cancel,
+    },
+  );
+}
+```
diff --git a/package.json b/package.json
index d49970b..3b58818 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,6 @@
     "build": "tsup",
     "check": "tsc --project tsconfig.source.json",
     "check:dist": "tsc --project tsconfig.dist.json",
-    "coverage": "vitest run --coverage",
     "docs:build": "cd docs && npm ci && npm run build:all",
     "lint": "eslint --fix --max-warnings 0 --cache --cache-location ./node_modules/.cache/eslint/",
     "lint:build": "attw --pack . && publint",
@@ -57,6 +56,7 @@
     "prettier": "prettier . --write",
     "release": "semantic-release",
     "test": "tsc --project tsconfig.tests.json && vitest --typecheck.enabled --typecheck.ignoreSourceErrors",
+    "test:coverage": "vitest run --coverage",
     "test:runtime": "tsc --project tsconfig.tests.json && vitest",
     "test:typing": "vitest --typecheck.only --typecheck.ignoreSourceErrors"
   },
diff --git a/src/debounce.ts b/src/debounce.ts
index 4c0ac78..b76e1c2 100644
--- a/src/debounce.ts
+++ b/src/debounce.ts
@@ -57,6 +57,8 @@ type DebounceOptions = {
  * It stores the value returned by `func` whenever its invoked. This value is returned on every call, and is accessible via the `cachedValue` property of the debouncer. Its important to note that the value might be different from the value that would be returned from running `func` with the current arguments as it is a cached value from a previous invocation.
  * **Important**: The cool-down period defines the minimum between two invocations, and not the maximum. The period will be **extended** each time a call is made until a full cool-down period has elapsed without any additional calls.
  *
+ *! **DEPRECATED**: This implementation of debounce is known to have issues and might not behave as expected. It should be replaced with the `funnel` utility instead. The test file [funnel.remeda-debounce.test.ts](https://github.com/remeda/remeda/blob/main/src/funnel.remeda-debounce.test.ts) offers a reference implementation that replicates `debounce` via `funnel`!
+ *
  * @param func - The function to debounce, the returned `call` function will have
  * the exact same signature.
  * @param options - An object allowing further customization of the debouncer:
@@ -96,6 +98,10 @@ type DebounceOptions = {
  *   debouncer.cachedValue; // => 3
  * @dataFirst
  * @category Function
+ * @deprecated This implementation of debounce is known to have issues and might
+ * not behave as expected. It should be replaced with the `funnel` utility
+ * instead. The test file `funnel.remeda-debounce.test.ts` offers a reference
+ * implementation that replicates `debounce` via `funnel`.
  * @see https://css-tricks.com/debouncing-throttling-explained-examples/
  */
 export function debounce<F extends (...args: any) => any>(
diff --git a/src/funnel.ts b/src/funnel.ts
new file mode 100644
index 0000000..1285e4e
--- /dev/null
+++ b/src/funnel.ts
@@ -0,0 +1,305 @@
+import type { RequireAtLeastOne } from "type-fest";
+
+type FunnelOptions<Args extends RestArguments, R> = {
+  readonly reducer?: (accumulator: R | undefined, ...params: Args) => R;
+} & FunnelTimingOptions;
+
+// Not all combinations of timing options are valid, there are dependencies
+// between them to ensure users can't configure the funnel in a way which would
+// cause it to never trigger.
+type FunnelTimingOptions =
+  | ({ readonly triggerAt?: "end" } & (
+      | ({ readonly minGapMs: number } & RequireAtLeastOne<{
+          readonly minQuietPeriodMs: number;
+          readonly maxBurstDurationMs: number;
+        }>)
+      | {
+          readonly minQuietPeriodMs?: number;
+          readonly maxBurstDurationMs?: number;
+          readonly minGapMs?: never;
+        }
+    ))
+  | {
+      readonly triggerAt: "start" | "both";
+      readonly minQuietPeriodMs?: number;
+      readonly maxBurstDurationMs?: number;
+      readonly minGapMs?: number;
+    };
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TypeScript has some quirks with generic function types, and works best with `any` and not `unknown`. This follows the typing of built-in utilities like `ReturnType` and `Parameters`.
+type RestArguments = Array<any>;
+
+type Funnel<Args extends RestArguments = []> = {
+  /**
+   * Call the function. This might result in the `execute` function being called
+   * now or later, depending on it's configuration and it's current state.
+   *
+   * @param args - The args are defined by the `reducer` function.
+   */
+
+  readonly call: (...args: Args) => void;
+
+  /**
+   * Resets the funnel to it's initial state. Any calls made since the last
+   * invocation will be discarded.
+   */
+  readonly cancel: () => void;
+
+  /**
+   * Triggers an invocation regardless of the current state of the funnel.
+   * Like any other invocation, The funnel will also be reset to it's initial
+   * state afterwards.
+   */
+  readonly flush: () => void;
+
+  /**
+   * The funnel is in it's initial state (there are no active timeouts).
+   */
+  readonly isIdle: boolean;
+};
+
+/**
+ * Creates a funnel that controls the timing and execution of `callback`. Its
+ * main purpose is to manage multiple consecutive (usually fast-paced) calls,
+ * reshaping them according to a defined batching strategy and timing policy.
+ * This is useful when handling uncontrolled call rates, such as DOM events or
+ * network traffic. It can implement strategies like debouncing, throttling,
+ * batching, and more.
+ *
+ * An optional `reducer` function can be provided to allow passing data to the
+ * callback via calls to `call` (otherwise the signature of `call` takes no
+ * arguments).
+ *
+ * Typing is inferred from `callback`s param, and from the rest params that
+ * the optional `reducer` function accepts. Use **explicit** types for these
+ * to ensure that everything _else_ is well-typed.
+ *
+ * Notice that this function constructs a funnel **object**, and does **not**
+ * execute anything when called. The returned object should be used to execute
+ * the funnel via the its `call` method.
+ *
+ * - Debouncing: use `minQuietPeriodMs` and any `triggerAt`.
+ * - Throttling: use `minGapMs` and `triggerAt: "start"` or `"both"`.
+ * - Batching: See the reference implementation in [`funnel.reference-batch.test.ts`](https://github.com/remeda/remeda/blob/main/src/funnel.reference-batch.test.ts).
+ *
+ * @param callback - The main function that would be invoked periodically based
+ * on `options`. The function would take the latest result of the `reducer`; if
+ * no calls where made since the last time it was invoked it will not be
+ * invoked. (If a return value is needed, it should be passed via a reference or
+ * via closure to the outer scope of the funnel).
+ * @param options - An object that defines when `execute` should be invoked,
+ * relative to the calls of `call`. An empty/missing options object is
+ * equivalent to setting `minQuietPeriodMs` to `0`.
+ * @param options.reducer - Combines the arguments passed to `call` with the
+ * value computed on the previous call (or `undefined` on the first time). The
+ * goal of the function is to extract and summarize the data needed for
+ * `callback`. It should be fast and simple as it is called often and should
+ * defer heavy operations to the `execute` function. If the final value
+ * is `undefined`, `callback` will not be called.
+ * @param options.triggerAt - At what "edges" of the funnel's burst window
+ * would `execute` invoke:
+ * - `start` - the function will be invoked immediately (within the  **same**
+ * execution frame!), and any subsequent calls would be ignored until the funnel
+ * is idle again. During this period `reducer` will also not be called.
+ * - `end` - the function will **not** be invoked initially but the timer will
+ * be started. Any calls during this time would be passed to the reducer, and
+ * when the timers are done, the reduced result would trigger an invocation.
+ * - `both` - the function will be invoked immediately, and then the funnel
+ * would behave as if it was in the 'end' state. @default 'end'.
+ * @param options.minQuietPeriodMs - The burst timer prevents subsequent calls
+ * in short succession to cause excessive invocations (aka "debounce"). This
+ * duration represents the **minimum** amount of time that needs to pass
+ * between calls (the "quiet" part) in order for the subsequent call to **not**
+ * be considered part of the burst. In other words, as long as calls are faster
+ * than this, they are considered part of the burst and the burst is extended.
+ * @param options.maxBurstDurationMs - Bursts are extended every time a call is
+ * made within the burst period. This means that the burst period could be
+ * extended indefinitely. To prevent such cases, a maximum burst duration could
+ * be defined. When `minQuietPeriodMs` is not defined and this option is, they
+ * will both share the same value.
+ * @param options.minGapMs - A minimum duration between calls of `execute`.
+ * This is maintained regardless of the shape of the burst and is ensured even
+ * if the `maxBurstDurationMs` is reached before it. (aka "throttle").
+ * @returns A funnel with a `call` function that is used to trigger invocations.
+ * In addition to it the funnel also comes with the following functions and
+ * properties:
+ * - `cancel` - Resets the funnel to it's initial state, discarding the current
+ * `reducer` result without calling `execute` on it.
+ * - `flush` - Triggers an invocation even if there are active timeouts, and
+ * then resets the funnel to it's initial state.
+ * - `isIdle` - Checks if there are any active timeouts.
+ * @signature
+ *   R.funnel(callback, options);
+ * @example
+ *   const debouncer = R.funnel(
+ *     () => {
+ *       console.log("Callback executed!");
+ *     },
+ *     { minQuietPeriodMs: 100 },
+ *   );
+ *   debouncer.call();
+ *   debouncer.call();
+ *
+ *   const throttle = R.funnel(
+ *     () => {
+ *       console.log("Callback executed!");
+ *     },
+ *     { minGapMs: 100, triggerAt: "start" },
+ *   );
+ *   throttle.call();
+ *   throttle.call();
+ * @category Function
+ */
+export function funnel<Args extends RestArguments = [], R = never>(
+  callback: (data: R) => void,
+  {
+    triggerAt = "end",
+    minQuietPeriodMs,
+    maxBurstDurationMs,
+    minGapMs,
+    reducer = voidReducer,
+  }: FunnelOptions<Args, R>,
+): Funnel<Args> {
+  // We manage execution via 2 timeouts, one to track bursts of calls, and one
+  // to track the interval between invocations. Together we refer to the period
+  // where any of these are active as a "cool-down period".
+  let burstTimeoutId: ReturnType<typeof setTimeout> | undefined;
+  let intervalTimeoutId: ReturnType<typeof setTimeout> | undefined;
+
+  // Until invoked, all calls are reduced into a single value that would be sent
+  // to the executor on invocation.
+  let preparedData: R | undefined;
+
+  // In order to be able to limit the total size of the burst (when
+  // `maxBurstDurationMs` is used) we need to track when the burst started.
+  let burstStartTimestamp: number | undefined;
+
+  const invoke = (): void => {
+    const param = preparedData;
+    if (param === undefined) {
+      // There were no calls during both cool-down periods.
+      return;
+    }
+
+    // Make sure the args aren't accidentally used again
+    preparedData = undefined;
+
+    callback(param);
+
+    if (minGapMs !== undefined) {
+      intervalTimeoutId = setTimeout(handleIntervalEnd, minGapMs);
+    }
+  };
+
+  const handleIntervalEnd = (): void => {
+    // When called via a timeout the timeout is already cleared, but when called
+    // via `flush` we need to manually clear it.
+    clearTimeout(intervalTimeoutId);
+    intervalTimeoutId = undefined;
+
+    if (burstTimeoutId !== undefined) {
+      // As long as one of the timeouts is active we don't invoke the function.
+      // Each timeout's end event handler has a call to invoke, so we are
+      // guaranteed to invoke the function eventually.
+      return;
+    }
+
+    invoke();
+  };
+
+  const handleBurstEnd = (): void => {
+    // When called via a timeout the timeout is already cleared, but when called
+    // via `flush` we need to manually clear it.
+    clearTimeout(burstTimeoutId);
+    burstTimeoutId = undefined;
+    burstStartTimestamp = undefined;
+
+    if (intervalTimeoutId !== undefined) {
+      // As long as one of the timeouts is active we don't invoke the function.
+      // Each timeout's end event handler has a call to invoke, so we are
+      // guaranteed to invoke the function eventually.
+      return;
+    }
+
+    invoke();
+  };
+
+  return {
+    call: (...args) => {
+      // We act based on the initial state of the timeouts before the call is
+      // handled and causes the timeouts to change.
+      const wasIdle =
+        burstTimeoutId === undefined && intervalTimeoutId === undefined;
+
+      if (triggerAt !== "start" || wasIdle) {
+        preparedData = reducer(preparedData, ...args);
+      }
+
+      if (burstTimeoutId === undefined && !wasIdle) {
+        // We are not in an active burst period but in an interval period. We
+        // don't start a new burst window until the next invoke.
+        return;
+      }
+
+      if (
+        minQuietPeriodMs !== undefined ||
+        maxBurstDurationMs !== undefined ||
+        minGapMs === undefined
+      ) {
+        // The timeout tracking the burst period needs to be reset every time
+        // another call is made so that it waits the full cool-down duration
+        // before it is released.
+        clearTimeout(burstTimeoutId);
+
+        const now = Date.now();
+
+        burstStartTimestamp ??= now;
+
+        const burstRemainingMs =
+          maxBurstDurationMs === undefined
+            ? (minQuietPeriodMs ?? 0)
+            : Math.min(
+                minQuietPeriodMs ?? maxBurstDurationMs,
+                // We need to account for the time already spent so that we
+                // don't wait longer than the maxDelay.
+                maxBurstDurationMs - (now - burstStartTimestamp),
+              );
+
+        burstTimeoutId = setTimeout(handleBurstEnd, burstRemainingMs);
+      }
+
+      if (triggerAt !== "end" && wasIdle) {
+        invoke();
+      }
+    },
+
+    cancel: () => {
+      clearTimeout(burstTimeoutId);
+      burstTimeoutId = undefined;
+      burstStartTimestamp = undefined;
+
+      clearTimeout(intervalTimeoutId);
+      intervalTimeoutId = undefined;
+
+      preparedData = undefined;
+    },
+
+    flush: () => {
+      handleBurstEnd();
+      handleIntervalEnd();
+    },
+
+    get isIdle() {
+      return burstTimeoutId === undefined && intervalTimeoutId === undefined;
+    },
+  };
+}
+
+// We use the value provided by the reducer to also determine if a call
+// was done during a timeout period. This means that even when no reducer
+// is provided, we still need a dummy reducer that would return something
+// other than `undefined`. It is safe to cast this to R (which might be
+// anything) because the callback would never use it as it would be typed
+// as a zero-args function.
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- Intentional, so that it could be used as a default value above.
+const voidReducer = <R>(): R => "" as R;
diff --git a/src/index.ts b/src/index.ts
index ab0ba29..fe7a9ba 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -38,6 +38,7 @@ export * from "./forEach";
 export * from "./forEachObj";
 export * from "./fromEntries";
 export * from "./fromKeys";
+export * from "./funnel";
 export * from "./groupBy";
 export * from "./hasAtLeast";
 export * from "./hasSubObject";


Test patch:
diff --git a/src/debounce.test-d.ts b/src/debounce.test-d.ts
index e95d356..43652d5 100644
--- a/src/debounce.test-d.ts
+++ b/src/debounce.test-d.ts
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/no-deprecated -- We know! */
+
 import { debounce } from "./debounce";
 import { identity } from "./identity";
 
diff --git a/src/debounce.test.ts b/src/debounce.test.ts
index f42659e..57f3ad7 100644
--- a/src/debounce.test.ts
+++ b/src/debounce.test.ts
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/no-deprecated -- We know! */
+
 import { debounce } from "./debounce";
 import { identity } from "./identity";
 
diff --git a/src/funnel.lodash-debounce-with-cached-value.test.ts b/src/funnel.lodash-debounce-with-cached-value.test.ts
new file mode 100644
index 0000000..6a26f05
--- /dev/null
+++ b/src/funnel.lodash-debounce-with-cached-value.test.ts
@@ -0,0 +1,256 @@
+/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any, jsdoc/require-param, jsdoc/require-example --
+ * These aren't useful for a reference implementation for a legacy library!
+ */
+
+import { sleep } from "../test/sleep";
+import { constant } from "./constant";
+import { funnel } from "./funnel";
+import { identity } from "./identity";
+
+/**
+ * A reference implementation of the Lodash `debounce` function using the
+ * Remeda `funnel` function. While migrating from Lodash you can copy this
+ * function as-is into your codebase and use it as a drop-in replacement; but
+ * we recommend eventually inlining the call to `funnel` so you can adjust the
+ * function to your specific needs.
+ *
+ * This is a more complex implementation which respects Lodash capability to
+ * track the return value of the callback function. In most cases you are more
+ * likely to prefer the simpler reference implementation `debounce` which can
+ * be found in the other test file.
+ *
+ * The following tests in this file are based on the Lodash tests for debounce.
+ * They have been adapted to work with our testing framework, have been fixed
+ * or expanded slightly were it felt necessary, and have been modernized for
+ * better readability. The names of the test cases have been preserved to ease
+ * comparing them to the original tests. Tests that are unrelated to the cache
+ * capability have been removed to avoid duplication with the other test file.
+ *
+ * Note that this means that whenever Lodash offered a concrete spec, we made
+ * sure our reference implementation respects it, but there might be untested
+ * use-cases that would have differing runtime behaviors.
+ *
+ * @see Lodash Documentation: https://lodash.com/docs/4.17.15#debounce
+ * @see Lodash Implementation: https://github.com/lodash/lodash/blob/4.17.21/lodash.js#L10372
+ * @see Lodash Tests: https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L4187
+ * @see Lodash Typing: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/lodash/common/function.d.ts#L374
+ */
+function debounceWithCachedValue<F extends (...args: any) => any>(
+  func: F,
+  wait = 0,
+  {
+    leading = false,
+    trailing = true,
+    maxWait,
+  }: {
+    readonly leading?: boolean;
+    readonly trailing?: boolean;
+    readonly maxWait?: number;
+  } = {},
+) {
+  let cachedValue: ReturnType<F> | undefined;
+
+  const { call, flush, cancel } = funnel(
+    (args: Parameters<F>) => {
+      if (!leading && !trailing) {
+        // In Lodash you can disable both the trailing and leading edges of the
+        // debounce window, effectively causing the function to never be
+        // invoked. Remeda uses the invokedAt enum exactly to prevent such a
+        // situation; so to simulate Lodash we need to only pass the callback
+        // when at least one of them is enabled.
+        return;
+      }
+
+      // Funnel provides more control over the args, but lodash simply passes
+      // them through, to replicate this behavior we need to spread the args
+      // array maintained via the reducer below.
+      // Also, every time the function is invoked the cached value is updated.
+      cachedValue = func(...args) as ReturnType<F>;
+    },
+    {
+      // Debounce stores the latest args it was called with for the next
+      // invocation of the callback.
+      reducer: (_, ...args: Parameters<F>) => args,
+      minQuietPeriodMs: wait,
+      ...(maxWait !== undefined && { maxBurstDurationMs: maxWait }),
+      ...(trailing
+        ? leading
+          ? { triggerAt: "both" }
+          : { triggerAt: "end" }
+        : { triggerAt: "start" }),
+    },
+  );
+
+  // Lodash uses a legacy JS-isms to attach helper functions to the main
+  // callback of `debounce`. In Remeda we return a proper object where the
+  // callback is one of the available properties. Here we destructure and then
+  // reconstruct the object to fit the Lodash API.
+  return Object.assign(
+    (...args: Parameters<F>) => {
+      call(...args);
+      return cachedValue;
+    },
+    {
+      flush: () => {
+        flush();
+        return cachedValue;
+      },
+
+      cancel,
+    },
+  );
+}
+
+// We need some non-trivial duration to use in all our tests, to abstract the
+// actual chosen value we use this UnitOfTime (UT) constant. As long as it is a
+// positive integer, the actual value doesn't matter (but the larger it is,
+// the longer the tests would take to run); the value used by Lodash is 32.
+// The number is in milliseconds.
+const UT = 16;
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L4187", () => {
+  it("should debounce a function", async () => {
+    const mockFn = vi.fn(identity());
+    const debounced = debounceWithCachedValue(mockFn, UT);
+
+    expect(debounced("a")).toBeUndefined();
+    expect(debounced("b")).toBeUndefined();
+    expect(debounced("c")).toBeUndefined();
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(debounced("d")).toBe("c");
+    expect(debounced("e")).toBe("c");
+    expect(debounced("f")).toBe("c");
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("subsequent debounced calls return the last `func` result", async () => {
+    const debounced = debounceWithCachedValue(identity(), UT);
+    debounced("a");
+    await sleep(2 * UT);
+
+    expect(debounced("b")).not.toBe("b");
+
+    await sleep(2 * UT);
+
+    expect(debounced("c")).not.toBe("c");
+  });
+
+  it("subsequent leading debounced calls return the last `func` result", async () => {
+    const debounced = debounceWithCachedValue(identity(), UT, {
+      leading: true,
+      trailing: false,
+    });
+
+    expect(debounced("a")).toBe("a");
+    expect(debounced("b")).toBe("a");
+
+    await sleep(2 * UT);
+
+    expect(debounced("c")).toBe("c");
+    expect(debounced("d")).toBe("c");
+  });
+
+  it("should invoke the trailing call with the correct arguments and `this` binding", async () => {
+    const DATA = {};
+    const mockFn = vi.fn(constant(false));
+
+    // In Lodash the test uses both `leading` and `trailing` timing options
+    // for this test, but it only works because the `leading` option in Lodash
+    // runs within the same execution frame as the call to the debouncer; the
+    // Lodash test also passes when the `leading` option is removed. For our
+    // implementation the "leading" option is delayed to the next execution
+    // frame, which, when used together with `maxWait` would cause the debouncer
+    // to see a "quiet window" and trigger an additional invocation of the
+    // mockFn, and the test to fail.
+    const debounced = debounceWithCachedValue(mockFn, UT, { maxWait: 2 * UT });
+
+    while (debounced(DATA, "a") ?? true) {
+      // eslint-disable-next-line no-await-in-loop -- We sleep to yield execution so that the timeouts in the debouncer have a chance to run.
+      await sleep(0);
+    }
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+    expect(mockFn).toHaveBeenLastCalledWith(DATA, "a");
+  });
+});
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L23038", () => {
+  it("should reset `lastCalled` after cancelling", async () => {
+    let callCount = 0;
+    const debounced = debounceWithCachedValue(
+      () => {
+        callCount += 1;
+        return callCount;
+      },
+      UT,
+      { leading: true },
+    );
+
+    expect(debounced()).toBe(1);
+
+    debounced.cancel();
+
+    expect(debounced()).toBe(2);
+
+    debounced();
+    await sleep(2 * UT);
+
+    expect(callCount).toBe(3);
+  });
+
+  it("should support flushing delayed calls", async () => {
+    let callCount = 0;
+    const debounced = debounceWithCachedValue(
+      () => {
+        callCount += 1;
+        return callCount;
+      },
+      UT,
+      { leading: false },
+    );
+    debounced();
+
+    expect(debounced.flush()).toBe(1);
+
+    await sleep(2 * UT);
+
+    expect(callCount).toBe(1);
+  });
+
+  it("should noop `cancel` and `flush` when nothing is queued", async () => {
+    const mockFn = vi.fn(constant("hello"));
+    const debounced = debounceWithCachedValue(mockFn, UT);
+    debounced.cancel();
+
+    expect(debounced.flush()).toBeUndefined();
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+});
+
+describe("Features not tested by Lodash", () => {
+  it("does nothing when neither leading nor trailing are enabled", async () => {
+    const debounced = debounceWithCachedValue(identity(), UT, {
+      leading: false,
+      trailing: false,
+    });
+
+    expect(debounced("hello")).toBeUndefined();
+    expect(debounced("world")).toBeUndefined();
+
+    await sleep(4 * UT);
+
+    expect(debounced("goodbye")).toBeUndefined();
+  });
+});
diff --git a/src/funnel.lodash-debounce.test.ts b/src/funnel.lodash-debounce.test.ts
new file mode 100644
index 0000000..9fe9e0d
--- /dev/null
+++ b/src/funnel.lodash-debounce.test.ts
@@ -0,0 +1,395 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any, jsdoc/require-param, jsdoc/require-example --
+ * These aren't useful for a reference implementation for a legacy library!
+ */
+
+import { sleep } from "../test/sleep";
+import { funnel } from "./funnel";
+
+/**
+ * A reference implementation of the Lodash `debounce` function using the
+ * Remeda `funnel` function. While migrating from Lodash you can copy this
+ * function as-is into your code base and use it as a drop-in replacement; but
+ * we recommend eventually inlining the call to `funnel` so you can adjust the
+ * function to your specific needs.
+ *
+ * This is a simplified implementation which ignores the Lodash capability to
+ * track the return value of the callback function, but it is most likely the
+ * more common use-case. For a more complete (and more complex) implementation
+ * that also handles that requirement see the reference implementation for
+ * `debounceWithCachedValue` in the other test file.
+ *
+ * The following tests in this file are based on the Lodash tests for debounce.
+ * They have been adapted to work with our testing framework, have been fixed
+ * or expanded slightly were it felt necessary, and have been modernized for
+ * better readability. The names of the test cases have been preserved to ease
+ * comparing them to the original tests.
+ *
+ * Note that this means that whenever Lodash offered a concrete spec, we made
+ * sure our reference implementation respects it, but there might be untested
+ * use-cases that would have differing runtime behaviors.
+ *
+ * @see Lodash Documentation: https://lodash.com/docs/4.17.15#debounce
+ * @see Lodash Implementation: https://github.com/lodash/lodash/blob/4.17.21/lodash.js#L10372
+ * @see Lodash Typing: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/lodash/common/function.d.ts#L374
+ */
+function debounce<F extends (...args: any) => void>(
+  func: F,
+  wait = 0,
+  {
+    leading = false,
+    trailing = true,
+    maxWait,
+  }: {
+    readonly leading?: boolean;
+    readonly trailing?: boolean;
+    readonly maxWait?: number;
+  } = {},
+) {
+  const {
+    call,
+    // Lodash v4 doesn't provide access to the `isIdle` (called `pending` in
+    // Lodash v5) information.
+    isIdle: _isIdle,
+    ...rest
+  } = funnel(
+    (args: Parameters<F>) => {
+      if (!trailing && !leading) {
+        // In Lodash you can disable both the trailing and leading edges of the
+        // debounce window, effectively causing the function to never be
+        // invoked. Remeda uses the invokedAt enum exactly to prevent such a
+        // situation; so to simulate Lodash we need to only pass the callback
+        // when at least one of them is enabled.
+        return;
+      }
+
+      // Funnel provides more control over the args, but lodash simply passes
+      // them through, to replicate this behavior we need to spread the args
+      // array maintained via the reducer below.
+      func(...args);
+    },
+    {
+      // Debounce stores the latest args it was called with for the next
+      // invocation of the callback.
+      reducer: (_, ...args: Parameters<F>) => args,
+      minQuietPeriodMs: wait,
+      ...(maxWait !== undefined && { maxBurstDurationMs: maxWait }),
+      ...(trailing
+        ? leading
+          ? { triggerAt: "both" }
+          : { triggerAt: "end" }
+        : { triggerAt: "start" }),
+    },
+  );
+
+  // Lodash uses a legacy JS-ism to attach helper functions to the main
+  // callback of `debounce`. In Remeda we return a proper object where the
+  // callback is one of the available properties. Here we destructure and then
+  // reconstruct the object to fit the Lodash API.
+  return Object.assign(call, rest);
+}
+
+// We need some non-trivial duration to use in all our tests, to abstract the
+// actual chosen value we use this UnitOfTime (UT) constant. As long as it is a
+// positive integer, the actual value doesn't matter (but the larger it is,
+// the longer the tests would take to run); the value used by Lodash is 32.
+// The number is in milliseconds.
+const UT = 16;
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L4187", () => {
+  it("should debounce a function", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT);
+    debounced("a");
+    debounced("b");
+    debounced("c");
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenCalledWith("c");
+
+    debounced("d");
+    debounced("e");
+    debounced("f");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should not immediately call `func` when `wait` is `0`", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, 0);
+    debounced();
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await yieldExecution();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should apply default options", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT, {});
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should support a `leading` option", async () => {
+    const mockWithLeading = vi.fn();
+    const mockWithLeadingAndTrailing = vi.fn();
+    const withLeading = debounce(mockWithLeading, UT, {
+      leading: true,
+      // This Lodash test configures both debouncers with the same timing
+      // options which doesn't seem to be the intent of the test based on the test
+      // name and the debouncer names. We fixed it in our test.
+      trailing: false,
+    });
+    const withLeadingAndTrailing = debounce(mockWithLeadingAndTrailing, UT, {
+      leading: true,
+      trailing: true,
+    });
+    withLeading();
+
+    expect(mockWithLeading).toHaveBeenCalledTimes(1);
+
+    withLeadingAndTrailing();
+    withLeadingAndTrailing();
+
+    expect(mockWithLeadingAndTrailing).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    expect(mockWithLeading).toHaveBeenCalledTimes(1);
+    expect(mockWithLeadingAndTrailing).toHaveBeenCalledTimes(2);
+
+    withLeading();
+
+    expect(mockWithLeading).toHaveBeenCalledTimes(2);
+  });
+
+  it("should support a `trailing` option", async () => {
+    const mockWith = vi.fn();
+    const mockWithout = vi.fn();
+    const withTrailing = debounce(mockWith, UT, { trailing: true });
+    // The lodash test disabled `trailing` but doesn't enable `leading`
+    // resulting in a debouncer that has no effective timing policy. We handle
+    // this situation so we kept the test, but it might be have been what the
+    // test was supposed to test.
+    const withoutTrailing = debounce(mockWithout, UT, { trailing: false });
+    withTrailing();
+
+    expect(mockWith).toHaveBeenCalledTimes(0);
+
+    withoutTrailing();
+
+    expect(mockWithout).toHaveBeenCalledTimes(0);
+
+    await sleep(2 * UT);
+
+    expect(mockWith).toHaveBeenCalledTimes(1);
+    expect(mockWithout).toHaveBeenCalledTimes(0);
+  });
+
+  it("should support a `maxWait` option", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT, { maxWait: 2 * UT });
+    debounced();
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    debounced();
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should support `maxWait` in a tight loop", async () => {
+    const mockWith = vi.fn();
+    const mockWithout = vi.fn();
+    const withMaxWait = debounce(mockWith, 2 * UT, { maxWait: 4 * UT });
+    const withoutMaxWait = debounce(mockWithout, 3 * UT);
+    const end = Date.now() + 10 * UT;
+    while (Date.now() < end) {
+      withMaxWait();
+      withoutMaxWait();
+    }
+
+    // There's a bug in the Lodash test where they take the result before the
+    // sleep (setTimeout); but it still worked for them coincidentally because
+    // of how maxWait is implemented:
+    // In Lodash, when the maxWait is reached, the callback is invoked within
+    // the same execution frame (without a setTimeout). In Remeda we use a
+    // setTimeout even when it's effective delay is 0ms. This means that we fail
+    // the lodash test if we don't first yield the execution frame to get the
+    // timeout to run; Oddly, the Lodash test already had this yield in place
+    // (although removing it won't break the lodash test), so to fix the test we
+    // simply added more expects to show the difference in implementations.
+
+    expect(mockWithout).toHaveBeenCalledTimes(0);
+    expect(mockWith).toHaveBeenCalledTimes(0);
+
+    await yieldExecution();
+
+    expect(mockWithout).toHaveBeenCalledTimes(0);
+    expect(mockWith).toHaveBeenCalledTimes(1);
+  });
+
+  it("should queue a trailing call for subsequent debounced calls after `maxWait`", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, 6 * UT, { maxWait: 6 * UT });
+    debounced();
+    await sleep(5.5 * UT);
+    debounced();
+    await sleep(0.5 * UT);
+    debounced();
+    await sleep(0.5 * UT);
+    debounced();
+    await sleep(9.5 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should cancel `maxDelayed` when `delayed` is invoked", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT, { maxWait: 2 * UT });
+    debounced();
+    await sleep(4 * UT);
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should invoke the trailing call with the correct arguments and `this` binding", async () => {
+    const mockFn = vi.fn();
+    const DATA = {};
+    const debounced = debounce(mockFn, UT, {
+      leading: true,
+      maxWait: 2 * UT,
+    });
+    while (mockFn.mock.calls.length < 2) {
+      debounced(DATA, "a");
+      // eslint-disable-next-line no-await-in-loop
+      await yieldExecution();
+    }
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+    expect(mockFn).toHaveBeenLastCalledWith(DATA, "a");
+  });
+});
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L23038", () => {
+  it("should use a default `wait` of `0`", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn);
+    debounced();
+    await sleep(UT);
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("supports recursive calls", async () => {
+    const actual = [] as Array<string>;
+    const queue = ["a", "b", "c"];
+    const debounced = debounce((chr: string) => {
+      actual.push(chr);
+      const next = queue.shift();
+      if (next !== undefined) {
+        debounced(next);
+      }
+    }, UT);
+    debounced(queue.shift()!);
+
+    expect(actual).toStrictEqual([]);
+
+    await sleep(8 * UT);
+
+    expect(actual).toStrictEqual(["a", "b", "c"]);
+  });
+
+  it("should support cancelling delayed calls", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT, { leading: false });
+    debounced();
+    debounced.cancel();
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+
+  it("should reset `lastCalled` after cancelling", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT, { leading: true });
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    debounced.cancel();
+    debounced();
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+
+    debounced();
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(3);
+  });
+
+  it("should support flushing delayed calls", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT, { leading: false });
+    debounced();
+    debounced.flush();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should noop `cancel` and `flush` when nothing is queued", async () => {
+    const mockFn = vi.fn();
+    const debounced = debounce(mockFn, UT);
+    debounced.cancel();
+    debounced.flush();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+});
+
+/**
+ * Funnel relies on letting the timeouts run. To ensure that happens the tests
+ * need to yield execution.
+ */
+const yieldExecution = async () => await sleep(0);
diff --git a/src/funnel.lodash-throttle-with-cached-value.test.ts b/src/funnel.lodash-throttle-with-cached-value.test.ts
new file mode 100644
index 0000000..6cfb970
--- /dev/null
+++ b/src/funnel.lodash-throttle-with-cached-value.test.ts
@@ -0,0 +1,227 @@
+/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any, jsdoc/require-param, jsdoc/require-example --
+ * These aren't useful for a reference implementation for a legacy library!
+ */
+
+import { sleep } from "../test/sleep";
+import { constant } from "./constant";
+import { funnel } from "./funnel";
+import { identity } from "./identity";
+
+/**
+ * A reference implementation of the Lodash `throttle` function using the
+ * Remeda `funnel` function. While migrating from Lodash you can copy this
+ * function as-is into your codebase and use it as a drop-in replacement; but
+ * we recommend eventually inlining the call to `funnel` so you can adjust the
+ * function to your specific needs.
+ *
+ * This is a more complex implementation which respects Lodash capability to
+ * track the return value of the callback function. In most cases you are more
+ * likely to prefer the simpler reference implementation `throttle` which can
+ * be found in the other test file.
+ *
+ * The following tests in this file are based on the Lodash tests for throttle.
+ * They have been adapted to work with our testing framework, have been fixed
+ * or expanded slightly were it felt necessary, and have been modernized for
+ * better readability. The names of the test cases have been preserved to ease
+ * comparing them to the original tests. Tests that are unrelated to the cache
+ * capability have been removed to avoid duplication with the other test file.
+ *
+ * Note that this means that whenever Lodash offered a concrete spec, we made
+ * sure our reference implementation respects it, but there might be untested
+ * use-cases that would have differing runtime behaviors.
+ *
+ * @see Lodash Documentation: https://lodash.com/docs/4.17.15#throttle
+ * @see Lodash Implementation: https://github.com/lodash/lodash/blob/4.17.21/lodash.js#L10965
+ * @see Lodash Tests: https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L22768
+ * @see Lodash Typing: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/lodash/common/function.d.ts#L1347
+ */
+function throttleWithCachedValue<F extends (...args: any) => any>(
+  func: F,
+  wait = 0,
+  {
+    leading = true,
+    trailing = true,
+  }: { readonly leading?: boolean; readonly trailing?: boolean } = {},
+) {
+  let cachedValue: ReturnType<F> | undefined;
+
+  const { call, flush, cancel } = funnel(
+    (args: Parameters<F>) => {
+      if (!leading && !trailing) {
+        // In Lodash you can disable both the trailing and leading edges of the
+        // throttle window, effectively causing the function to never be
+        // invoked. Remeda uses the invokedAt enum exactly to prevent such a
+        // situation; so to simulate Lodash we need to only pass the callback
+        // when at least one of them is enabled.
+        return;
+      }
+
+      // Funnel provides more control over the args, but lodash simply passes
+      // them through, to replicate this behavior we need to spread the args
+      // array maintained via the reducer below.
+      // Also, every time the function is invoked the cached value is updated.
+      cachedValue = func(...args) as ReturnType<F>;
+    },
+    {
+      // Throttle stores the latest args it was called with for the next
+      // invocation of the callback.
+      reducer: (_, ...args: Parameters<F>) => args,
+      minQuietPeriodMs: wait,
+      maxBurstDurationMs: wait,
+      ...(trailing
+        ? leading
+          ? { triggerAt: "both" }
+          : { triggerAt: "end" }
+        : { triggerAt: "start" }),
+    },
+  );
+
+  // Lodash uses a legacy JS-isms to attach helper functions to the main
+  // callback of `throttle`. In Remeda we return a proper object where the
+  // callback is one of the available properties. Here we destructure and then
+  // reconstruct the object to fit the Lodash API.
+  return Object.assign(
+    (...args: Parameters<F>) => {
+      call(...args);
+      return cachedValue;
+    },
+    {
+      flush: () => {
+        flush();
+        return cachedValue;
+      },
+
+      cancel,
+    },
+  );
+}
+
+// We need some non-trivial duration to use in all our tests, to abstract the
+// actual chosen value we use this UnitOfTime (UT) constant. As long as it is a
+// positive integer, the actual value doesn't matter (but the larger it is,
+// the longer the tests would take to run); the value used by Lodash is 32.
+// The number is in milliseconds.
+const UT = 16;
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L22768", () => {
+  it("subsequent calls should return the result of the first call", async () => {
+    const throttled = throttleWithCachedValue(identity(), UT);
+
+    expect(throttled("a")).toBe("a");
+    expect(throttled("b")).toBe("a");
+
+    await sleep(2 * UT);
+    const resultC = throttled("c");
+    const resultD = throttled("d");
+
+    expect(resultC).not.toBe("a");
+    expect(resultC).toBeDefined();
+    expect(resultD).not.toBe("d");
+    expect(resultD).toBeDefined();
+  });
+
+  it("should support a `leading` option", () => {
+    const withLeading = throttleWithCachedValue(identity(), UT, {
+      leading: true,
+    });
+    const withoutLeading = throttleWithCachedValue(identity(), UT, {
+      leading: false,
+    });
+
+    expect(withLeading("a")).toBe("a");
+    expect(withoutLeading("a")).toBeUndefined();
+  });
+
+  it("should support a `trailing` option", async () => {
+    const mockWith = vi.fn(identity() as <T>(x: T) => T);
+    const mockWithout = vi.fn(identity() as <T>(x: T) => T);
+    const withTrailing = throttleWithCachedValue(mockWith, 2 * UT, {
+      trailing: true,
+    });
+    const withoutTrailing = throttleWithCachedValue(mockWithout, 2 * UT, {
+      trailing: false,
+    });
+
+    expect(withTrailing("a")).toBe("a");
+    expect(withTrailing("b")).toBe("a");
+    expect(withoutTrailing("a")).toBe("a");
+    expect(withoutTrailing("b")).toBe("a");
+
+    await sleep(8 * UT);
+
+    expect(mockWith).toHaveBeenCalledTimes(2);
+    expect(mockWithout).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L23038", () => {
+  it("should reset `lastCalled` after cancelling", async () => {
+    let callCount = 0;
+    const throttled = throttleWithCachedValue(
+      () => {
+        callCount += 1;
+        return callCount;
+      },
+      UT,
+      { leading: true },
+    );
+
+    expect(throttled()).toBe(1);
+
+    throttled.cancel();
+
+    expect(throttled()).toBe(2);
+
+    throttled();
+    await sleep(2 * UT);
+
+    expect(callCount).toBe(3);
+  });
+
+  it("should support flushing delayed calls", async () => {
+    let callCount = 0;
+    const throttled = throttleWithCachedValue(
+      () => {
+        callCount += 1;
+        return callCount;
+      },
+      UT,
+      { leading: false },
+    );
+    throttled();
+
+    expect(throttled.flush()).toBe(1);
+
+    await sleep(2 * UT);
+
+    expect(callCount).toBe(1);
+  });
+
+  it("should noop `cancel` and `flush` when nothing is queued", async () => {
+    const mockFn = vi.fn(constant("hello"));
+    const throttled = throttleWithCachedValue(mockFn, UT);
+    throttled.cancel();
+
+    expect(throttled.flush()).toBeUndefined();
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+});
+
+describe("Features not tested by Lodash", () => {
+  it("does nothing when neither leading nor trailing are enabled", async () => {
+    const throttled = throttleWithCachedValue(identity(), UT, {
+      leading: false,
+      trailing: false,
+    });
+
+    expect(throttled("hello")).toBeUndefined();
+    expect(throttled("world")).toBeUndefined();
+
+    await sleep(4 * UT);
+
+    expect(throttled("goodbye")).toBeUndefined();
+  });
+});
diff --git a/src/funnel.lodash-throttle.test.ts b/src/funnel.lodash-throttle.test.ts
new file mode 100644
index 0000000..64ec3cb
--- /dev/null
+++ b/src/funnel.lodash-throttle.test.ts
@@ -0,0 +1,338 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any, jsdoc/require-param, jsdoc/require-example --
+ * These aren't useful for a reference implementation for a legacy library!
+ */
+
+import { sleep } from "../test/sleep";
+import { funnel } from "./funnel";
+
+/**
+ * A reference implementation of the Lodash `throttle` function using the
+ * Remeda `funnel` function. While migrating from Lodash you can copy this
+ * function as-is into your code base and use it as a drop-in replacement; but
+ * we recommend eventually inlining the call to `funnel` so you can adjust the
+ * function to your specific needs.
+ *
+ * This is a simplified implementation which ignores the Lodash capability to
+ * track the return value of the callback function, but it is most likely the
+ * more common use-case. For a more complete (and more complex) implementation
+ * that also does that see the reference implementation for
+ * `throttleWithCachedValue` in the other test file.
+ *
+ * The following tests in this file are based on the Lodash tests for throttle.
+ * They have been adapted to work with our testing framework, have been fixed
+ * or expanded slightly were it felt necessary, and have been modernized for
+ * better readability. The names of the test cases have been preserved to ease
+ * comparing them to the original tests.
+ *
+ * Note that this means that whenever Lodash offered a concrete spec, we made
+ * sure our reference implementation respects it, but there might be untested
+ * use-cases that would have differing runtime behaviors.
+ *
+ * @see Lodash Documentation: https://lodash.com/docs/4.17.15#throttle
+ * @see Lodash Implementation: https://github.com/lodash/lodash/blob/4.17.21/lodash.js#L10965
+ * @see Lodash Typing: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/lodash/common/function.d.ts#L1347
+ */
+function throttle<F extends (...args: any) => void>(
+  func: F,
+  wait = 0,
+  {
+    leading = true,
+    trailing = true,
+  }: { readonly leading?: boolean; readonly trailing?: boolean } = {},
+) {
+  const {
+    call,
+    // Lodash v4 doesn't provide access to the `isIdle` (called `pending` in
+    // Lodash v5) information.
+    isIdle: _isIdle,
+    ...rest
+  } = funnel(
+    (args: Parameters<F>) => {
+      if (!leading && !trailing) {
+        // In Lodash you can disable both the trailing and leading edges of the
+        // throttle window, effectively causing the function to never be
+        // invoked. Remeda uses the invokedAt enum exactly to prevent such a
+        // situation; so to simulate Lodash we need to only pass the callback
+        // when at least one of them is enabled.
+        return;
+      }
+
+      // Funnel provides more control over the args, but lodash simply passes
+      // them through, to replicate this behavior we need to spread the args
+      // array maintained via the reducer below.
+      func(...args);
+    },
+    {
+      // Throttle stores the latest args it was called with for the next
+      // invocation of the callback.
+      reducer: (_, ...args: Parameters<F>) => args,
+      minQuietPeriodMs: wait,
+      maxBurstDurationMs: wait,
+      ...(trailing
+        ? leading
+          ? { triggerAt: "both" }
+          : { triggerAt: "end" }
+        : { triggerAt: "start" }),
+    },
+  );
+  // Lodash uses a legacy JS-ism to attach helper functions to the main
+  // callback of `throttle`. In Remeda we return a proper object where the
+  // callback is one of the available properties. Here we destructure and then
+  // reconstruct the object to fit the Lodash API.
+  return Object.assign(call, rest);
+}
+
+// We need some non-trivial duration to use in all our tests, to abstract the
+// actual chosen value we use this UnitOfTime (UT) constant. As long as it is a
+// positive integer, the actual value doesn't matter (but the larger it is,
+// the longer the tests would take to run); the value used by Lodash is 32.
+// The number is in milliseconds.
+const UT = 16;
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L22768", () => {
+  it("should throttle a function", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT);
+    throttled();
+    throttled();
+    throttled();
+    const callCount = mockFn.mock.calls.length;
+
+    expect(callCount).toBeGreaterThan(0);
+
+    await sleep(2 * UT);
+
+    expect(mockFn.mock.calls.length).toBeGreaterThan(callCount);
+  });
+
+  it("should clear timeout when `func` is called", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT);
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should not trigger a trailing call when invoked once", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT);
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should trigger a call when invoked repeatedly", () => {
+    const mockFn = vi.fn();
+
+    const throttled = throttle(mockFn, UT);
+
+    const end = Date.now() + 10 * UT;
+    while (Date.now() < end) {
+      throttled();
+    }
+
+    expect(mockFn).toHaveBeenCalledWith();
+  });
+
+  it("should trigger a call when invoked repeatedly and `leading` is `false`", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT, { leading: false });
+    const end = Date.now() + 10 * UT;
+    while (Date.now() < end) {
+      throttled();
+    }
+    // Yield execution to allow timeouts to run
+    await sleep(0);
+
+    expect(mockFn).toHaveBeenCalledWith();
+  });
+
+  it("should trigger a second throttled call as soon as possible", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, 4 * UT, { leading: false });
+    throttled();
+    await sleep(6 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    throttled();
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should apply default options", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT, {});
+    throttled();
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(4 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should support a `leading` option", () => {
+    const mockWith = vi.fn();
+    const mockWithout = vi.fn();
+    const withLeading = throttle(mockWith, UT, { leading: true });
+    const withoutLeading = throttle(mockWithout, UT, { leading: false });
+
+    withLeading();
+    withoutLeading();
+
+    expect(mockWith).toHaveBeenCalledTimes(1);
+    expect(mockWithout).toHaveBeenCalledTimes(0);
+  });
+
+  it("should support a `trailing` option", async () => {
+    const mockWith = vi.fn();
+    const mockWithout = vi.fn();
+    const withTrailing = throttle(mockWith, 2 * UT, { trailing: true });
+    const withoutTrailing = throttle(mockWithout, 2 * UT, { trailing: false });
+    withTrailing("a");
+    withTrailing("b");
+    withoutTrailing("a");
+    withoutTrailing("b");
+
+    expect(mockWith).toHaveBeenCalledTimes(1);
+    expect(mockWith).toHaveBeenCalledWith("a");
+    expect(mockWithout).toHaveBeenCalledTimes(1);
+    expect(mockWithout).toHaveBeenCalledWith("a");
+
+    await sleep(8 * UT);
+
+    expect(mockWith).toHaveBeenCalledTimes(2);
+    expect(mockWithout).toHaveBeenCalledTimes(1);
+  });
+
+  it("should not update `lastCalled`, at the end of the timeout, when `trailing` is `false`", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, 64, { trailing: false });
+    throttled();
+    throttled();
+    await sleep(3 * UT);
+    throttled();
+    throttled();
+    await sleep(3 * UT);
+
+    expect(mockFn).toHaveBeenCalledWith();
+  });
+});
+
+describe("https://github.com/lodash/lodash/blob/4.17.21/test/test.js#L23038", () => {
+  it("should use a default `wait` of `0`", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn);
+    throttled();
+    await sleep(UT);
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("supports recursive calls", async () => {
+    const actual = [] as Array<string>;
+    const queue = ["a", "b", "c"];
+    const throttled = throttle((chr: string) => {
+      actual.push(chr);
+      const next = queue.shift();
+      if (next !== undefined) {
+        throttled(next);
+      }
+    }, UT);
+    throttled(queue.shift()!);
+
+    expect(actual).toStrictEqual(["a"]);
+
+    await sleep(8 * UT);
+
+    expect(actual).toStrictEqual(["a", "b", "c"]);
+  });
+
+  it("should support cancelling delayed calls", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT, { leading: false });
+    throttled();
+    throttled.cancel();
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+
+  it("should reset `lastCalled` after cancelling", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT, { leading: true });
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    throttled.cancel();
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+
+    throttled();
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(3);
+  });
+
+  it("should support flushing delayed calls", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT, { leading: false });
+    throttled();
+    throttled.flush();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should noop `cancel` and `flush` when nothing is queued", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT);
+    throttled.cancel();
+    throttled.flush();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+});
+
+describe("Not tested by Lodash", () => {
+  it("should do nothing when `leading` and `trailing` are both `disabled`", async () => {
+    const mockFn = vi.fn();
+    const throttled = throttle(mockFn, UT, { leading: false, trailing: false });
+    throttled();
+    throttled();
+    throttled();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/src/funnel.reference-batch.test.ts b/src/funnel.reference-batch.test.ts
new file mode 100644
index 0000000..492c28d
--- /dev/null
+++ b/src/funnel.reference-batch.test.ts
@@ -0,0 +1,200 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type,  @typescript-eslint/no-explicit-any,  @typescript-eslint/prefer-readonly-parameter-types, @typescript-eslint/require-await,  @typescript-eslint/use-unknown-in-catch-callback-variable, jsdoc/require-example --
+ * These aren't useful for a reference implementation!
+ */
+
+import { doNothing } from "./doNothing";
+import { fromKeys } from "./fromKeys";
+import { funnel } from "./funnel";
+
+type BatchRequest<Params extends Array<any>, Result> = {
+  readonly params: Params;
+  readonly promiseCallbacks: Readonly<
+    Parameters<ConstructorParameters<typeof Promise<Result>>[0]>
+  >;
+};
+
+/**
+ * A reference implementation for an async batching utility function built on
+ * top of the `funnel` general purpose execution utility function. It will
+ * accumulate all params passed to an async `call` method within the defined
+ * burst duration and then use an async executor to process them in one
+ * invocation. It then extracts an individual result for each call which is
+ * used to resolve the original call.
+ *
+ * This allows synchronizing multiple async calls while keeping each call site
+ * isolated from the rest (for example, as react components).
+ *
+ * This reference implementation can be copied into your project as-is, or you
+ * can use it as the basis for a more complex implementation with additional
+ * features.
+ *
+ * @param callback - The main function that takes a batch and returns an
+ * aggregated response. The typing for the it's parameters will derive the
+ * typing for the extractor and the `call` method.
+ * @param extractor - A function that takes the aggregated response and extracts
+ * from it the result for each individual call. The function is called with both
+ * the index in the batch, and the params passed to the `call` method. This
+ * allows handling APIs that return batch results as both objects and arrays.
+ * @param maxBurstDurationMs - The period of time where the batcher would
+ * collect params before triggering the executor. When set to 0 the batcher
+ * does not incur any additional delays to the execution and would trigger at
+ * the next tick, just like a regular async function would. This is also the
+ * default value.
+ * @returns A Funnel object with the `call` method augmented to support async
+ * response.
+ */
+function batch<Params extends Array<any>, BatchResponse, Result>(
+  callback: (requests: ReadonlyArray<Params>) => Promise<BatchResponse>,
+  extractor: (
+    response: BatchResponse,
+    index: number,
+    ...params: Params
+  ) => Result,
+  maxBurstDurationMs = 0,
+) {
+  const batchFunnel = funnel(
+    // Passes all accumulated parameters to the callback and then extracts the response for each individual call via the extractor.
+    (requests: ReadonlyArray<BatchRequest<Params, Result>>) => {
+      callback(requests.map(({ params }) => params))
+        // On success we iterate again over all calls and allow the extractor
+        // to pull a value out of the aggregated response for each one.
+        .then((response) => {
+          for (const [
+            index,
+            {
+              params,
+              promiseCallbacks: [resolve],
+            },
+          ] of requests.entries()) {
+            const result = extractor(response, index, ...params);
+            resolve(result);
+          }
+        })
+
+        // On error we simply pass the error along to all reject callbacks.
+        .catch((error) => {
+          for (const {
+            promiseCallbacks: [, reject],
+          } of requests) {
+            reject(error);
+          }
+        });
+    },
+    {
+      // Reducer: Accumulates the parameters for each call, together with the
+      // promise executor callbacks needed to resolve or reject the call.
+      reducer: (requests, request: BatchRequest<Params, Result>) => [
+        ...(requests ?? []),
+        request,
+      ],
+      maxBurstDurationMs,
+      triggerAt: "end",
+    },
+  );
+
+  return {
+    ...batchFunnel,
+
+    call: async (...params: Params) =>
+      new Promise<Result>((...promiseCallbacks) => {
+        batchFunnel.call({ promiseCallbacks, params });
+      }),
+  };
+}
+
+describe("showcase", () => {
+  test("results as object", async () => {
+    const mockApi = vi.fn(
+      async (words: ReadonlyArray<string>): Promise<Record<string, number>> =>
+        fromKeys(words, (word) => word.length),
+    );
+
+    const countLettersApi = batch(
+      // We only need to type the `requests` param of the `executor` callback.
+      // All other types are derived from it.
+      async (requests: ReadonlyArray<[word: string]>) =>
+        await mockApi(requests.flat()),
+      (response, _, word) => response[word],
+    );
+
+    const prepared = [
+      countLettersApi.call("short"),
+      countLettersApi.call("medium"),
+      countLettersApi.call("loooooooooooooong"),
+    ];
+
+    expect(mockApi).toHaveBeenCalledTimes(0);
+
+    const [shortResult, mediumResult, longResult] = await Promise.all(prepared);
+
+    expect(mockApi).toHaveBeenCalledTimes(1);
+    expect(mockApi).toHaveBeenLastCalledWith([
+      "short",
+      "medium",
+      "loooooooooooooong",
+    ]);
+    expect(mockApi).toHaveLastResolvedWith({
+      short: 5,
+      medium: 6,
+      loooooooooooooong: 17,
+    });
+    expect(shortResult).toBe(5);
+    expect(mediumResult).toBe(6);
+    expect(longResult).toBe(17);
+  });
+
+  test("results as array", async () => {
+    const mockApi = vi.fn(
+      async (words: ReadonlyArray<string>): Promise<ReadonlyArray<number>> =>
+        words.map((word) => word.length),
+    );
+
+    const countLettersApi = batch(
+      // We only need to type the `requests` param of the `executor` callback.
+      // All other types are derived from it.
+      async (requests: ReadonlyArray<[word: string]>) =>
+        await mockApi(requests.flat()),
+      (response, index) => response[index],
+    );
+
+    const prepared = [
+      countLettersApi.call("short"),
+      countLettersApi.call("medium"),
+      countLettersApi.call("loooooooooooooong"),
+    ];
+
+    expect(mockApi).toHaveBeenCalledTimes(0);
+
+    const [shortResult, mediumResult, longResult] = await Promise.all(prepared);
+
+    expect(mockApi).toHaveBeenCalledTimes(1);
+    expect(mockApi).toHaveBeenLastCalledWith([
+      "short",
+      "medium",
+      "loooooooooooooong",
+    ]);
+    expect(mockApi).toHaveLastResolvedWith([5, 6, 17]);
+    expect(shortResult).toBe(5);
+    expect(mediumResult).toBe(6);
+    expect(longResult).toBe(17);
+  });
+
+  test("error handling", async () => {
+    const failingApi = batch(
+      // We only need to type the `requests` param of the `executor` callback.
+      // All other types are derived from it.
+      async (requests: ReadonlyArray<[id: string]>) => {
+        if (requests.length > 1) {
+          throw new Error(`Batch too big! ${JSON.stringify(requests)}`);
+        }
+      },
+      doNothing(),
+    );
+
+    await expect(failingApi.call("a")).resolves.toBeUndefined();
+
+    await expect(
+      Promise.all([failingApi.call("hello"), failingApi.call("world")]),
+    ).rejects.toThrow('Batch too big! [["hello"],["world"]]');
+  });
+});
diff --git a/src/funnel.remeda-debounce.test.ts b/src/funnel.remeda-debounce.test.ts
new file mode 100644
index 0000000..3b0a3b6
--- /dev/null
+++ b/src/funnel.remeda-debounce.test.ts
@@ -0,0 +1,469 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type, jsdoc/require-param, jsdoc/require-example, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any --
+ * These aren't useful for a reference implementation!
+ */
+
+import { sleep } from "../test/sleep";
+import { funnel } from "./funnel";
+import { identity } from "./identity";
+
+type Debouncer<
+  F extends (...args: any) => unknown,
+  IsNullable extends boolean = true,
+> = {
+  readonly call: (
+    ...args: Parameters<F>
+  ) => ReturnType<F> | (true extends IsNullable ? undefined : never);
+  readonly cancel: () => void;
+  readonly flush: () => ReturnType<F> | undefined;
+  readonly isPending: boolean;
+  readonly cachedValue: ReturnType<F> | undefined;
+};
+
+type DebounceOptions = {
+  readonly waitMs?: number;
+  readonly maxWaitMs?: number;
+};
+
+/**
+ * A reference implementation of the now deprecated `debounce` function using
+ * the `funnel` function instead. While you update your codebase you can copy
+ * this function as-is and use it as a drop-in replacement; but we recommend
+ * eventually inlining the call to `funnel` so you can adjust the function to
+ * your specific needs.
+ *
+ * The following tests in this file are the original tests for debounce.
+ *
+ * @see debounce
+ */
+function debounce<F extends (...args: any) => any>(
+  func: F,
+  options: DebounceOptions & { readonly timing?: "trailing" },
+): Debouncer<F>;
+function debounce<F extends (...args: any) => any>(
+  func: F,
+  options:
+    | (DebounceOptions & { readonly timing: "both" })
+    | (Omit<DebounceOptions, "maxWaitMs"> & { readonly timing: "leading" }),
+): Debouncer<F, false /* call CAN'T return null */>;
+function debounce<F extends (...args: any) => any>(
+  func: F,
+  {
+    timing,
+    waitMs,
+    maxWaitMs,
+  }: DebounceOptions & {
+    readonly timing?: "both" | "leading" | "trailing";
+  },
+) {
+  if (maxWaitMs !== undefined && waitMs !== undefined && maxWaitMs < waitMs) {
+    throw new Error(
+      `debounce: maxWaitMs (${maxWaitMs.toString()}) cannot be less than waitMs (${waitMs.toString()})`,
+    );
+  }
+
+  let cachedValue: ReturnType<F> | undefined;
+
+  const debouncingFunnel = funnel(
+    (args: Parameters<F>) => {
+      // Every time the function is invoked the cached value is updated.
+      cachedValue = func(...args) as ReturnType<F>;
+    },
+    {
+      // Debounce stores the latest args it was called with for the next
+      // invocation of the callback.
+      reducer: (_, ...args: Parameters<F>) => args,
+      minQuietPeriodMs: waitMs ?? maxWaitMs ?? 0,
+      ...(maxWaitMs !== undefined && { maxBurstDurationMs: maxWaitMs }),
+      ...(timing === "leading"
+        ? { triggerAt: "start" }
+        : timing === "both"
+          ? { triggerAt: "both" }
+          : { triggerAt: "end" }),
+    },
+  );
+
+  return {
+    call: (...args: Parameters<F>) => {
+      debouncingFunnel.call(...args);
+      return cachedValue;
+    },
+
+    flush: () => {
+      debouncingFunnel.flush();
+      return cachedValue;
+    },
+
+    cancel: () => {
+      debouncingFunnel.cancel();
+    },
+
+    get isPending() {
+      return !debouncingFunnel.isIdle;
+    },
+
+    get cachedValue() {
+      return cachedValue;
+    },
+  };
+}
+
+describe("main functionality", () => {
+  it("should debounce a function", async () => {
+    const mockFn = vi.fn(identity());
+    const debouncer = debounce(mockFn, { waitMs: 32 });
+
+    expect([
+      debouncer.call("a"),
+      debouncer.call("b"),
+      debouncer.call("c"),
+    ]).toStrictEqual([undefined, undefined, undefined]);
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(128);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect([
+      debouncer.call("d"),
+      debouncer.call("e"),
+      debouncer.call("f"),
+    ]).toStrictEqual(["c", "c", "c"]);
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(256);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("subsequent debounced calls return the last `func` result", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+    debouncer.call("a");
+    await sleep(64);
+
+    expect(debouncer.call("b")).toBe("a");
+
+    await sleep(128);
+
+    expect(debouncer.call("c")).toBe("b");
+  });
+
+  it("should not immediately call `func` when `wait` is `0`", async () => {
+    const mockFn = vi.fn();
+    const debouncer = debounce(mockFn, {});
+    debouncer.call();
+    debouncer.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(5);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should apply default options", async () => {
+    const mockFn = vi.fn();
+    const debouncer = debounce(mockFn, { waitMs: 32 });
+    debouncer.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(64);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("should support a `leading` option", async () => {
+    const leadingMockFn = vi.fn();
+    const bothMockFn = vi.fn();
+    const withLeading = debounce(leadingMockFn, {
+      waitMs: 32,
+      timing: "leading",
+    });
+    withLeading.call();
+
+    expect(leadingMockFn).toHaveBeenCalledTimes(1);
+
+    const withLeadingAndTrailing = debounce(bothMockFn, {
+      waitMs: 32,
+      timing: "both",
+    });
+    withLeadingAndTrailing.call();
+    withLeadingAndTrailing.call();
+
+    expect(bothMockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(64);
+
+    expect(bothMockFn).toHaveBeenCalledTimes(2);
+
+    withLeading.call();
+
+    expect(leadingMockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("subsequent leading debounced calls return the last `func` result", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32, timing: "leading" });
+
+    expect([debouncer.call("a"), debouncer.call("b")]).toStrictEqual([
+      "a",
+      "a",
+    ]);
+
+    await sleep(64);
+
+    expect([debouncer.call("c"), debouncer.call("d")]).toStrictEqual([
+      "c",
+      "c",
+    ]);
+  });
+
+  it("should support a `trailing` option", async () => {
+    const mockFn = vi.fn();
+    const withTrailing = debounce(mockFn, { waitMs: 32, timing: "trailing" });
+    withTrailing.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(64);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("optional param maxWaitMs", () => {
+  it("should support a `maxWait` option", async () => {
+    const mockFn = vi.fn(identity());
+    const debouncer = debounce(mockFn, { waitMs: 32, maxWaitMs: 64 });
+    debouncer.call("a");
+    debouncer.call("b");
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(128);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    debouncer.call("c");
+    debouncer.call("d");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(256);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should support `maxWait` in a tight loop", async () => {
+    const withMockFn = vi.fn();
+    const withoutMockFn = vi.fn();
+    const withMaxWait = debounce(withMockFn, { waitMs: 32, maxWaitMs: 128 });
+    const withoutMaxWait = debounce(withoutMockFn, { waitMs: 96 });
+    const start = Date.now();
+    while (Date.now() - start < 320) {
+      withMaxWait.call();
+      withoutMaxWait.call();
+    }
+    await sleep(1);
+
+    expect(withoutMockFn).toHaveBeenCalledTimes(0);
+    expect(withMockFn).not.toHaveBeenCalledTimes(0);
+  });
+
+  it("should queue a trailing call for subsequent debounced calls after `maxWait`", async () => {
+    const mockFn = vi.fn();
+    const debouncer = debounce(mockFn, { waitMs: 200, maxWaitMs: 200 });
+    debouncer.call();
+    setTimeout(() => {
+      debouncer.call();
+    }, 190);
+    setTimeout(() => {
+      debouncer.call();
+    }, 200);
+    setTimeout(() => {
+      debouncer.call();
+    }, 210);
+    await sleep(500);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("should cancel `maxDelayed` when `delayed` is invoked", async () => {
+    const mockFn = vi.fn();
+    const debouncer = debounce(mockFn, { waitMs: 32, maxWaitMs: 64 });
+    debouncer.call();
+    await sleep(128);
+    debouncer.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(192);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+
+  it("works like a leaky bucket when only maxWaitMs is set", async () => {
+    const mockFn = vi.fn();
+    const debouncer = debounce(mockFn, { maxWaitMs: 32 });
+    debouncer.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(16);
+    // Without maxWaitMs this call would cause the actual invocation to be
+    // postponed for a full window.
+    debouncer.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(17);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("additional functionality", () => {
+  it("can cancel before the timer starts", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+
+    expect(() => {
+      debouncer.cancel();
+    }).not.toThrow();
+    expect(debouncer.call("hello")).toBeUndefined();
+
+    await sleep(32);
+
+    expect(debouncer.call("world")).toBe("hello");
+  });
+
+  it("can cancel the timer", async () => {
+    const data = "Hello, World!";
+    const mockFn = vi.fn(() => data);
+    const debouncer = debounce(mockFn, { waitMs: 32 });
+
+    expect(debouncer.call()).toBeUndefined();
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(1);
+
+    expect(debouncer.call()).toBeUndefined();
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    debouncer.cancel();
+    await sleep(32);
+
+    expect(debouncer.call()).toBeUndefined();
+    expect(mockFn).toHaveBeenCalledTimes(0);
+
+    await sleep(32);
+
+    expect(debouncer.call()).toStrictEqual(data);
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  it("can cancel after the timer ends", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+
+    expect(debouncer.call("hello")).toBeUndefined();
+
+    await sleep(32);
+
+    expect(debouncer.call("world")).toBe("hello");
+    expect(() => {
+      debouncer.cancel();
+    }).not.toThrow();
+  });
+
+  it("can cancel maxWait timer", async () => {
+    const debouncer = debounce(identity(), { waitMs: 16, maxWaitMs: 32 });
+
+    expect(debouncer.call("hello")).toBeUndefined();
+
+    await sleep(1);
+    debouncer.cancel();
+    await sleep(32);
+
+    expect(debouncer.call("world")).toBeUndefined();
+  });
+
+  it("can return a cached value", () => {
+    const debouncer = debounce(identity(), { timing: "leading", waitMs: 32 });
+
+    expect(debouncer.cachedValue).toBeUndefined();
+    expect(debouncer.call("hello")).toBe("hello");
+    expect(debouncer.cachedValue).toBe("hello");
+  });
+
+  it("can check for inflight timers (trailing)", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+
+    expect(debouncer.isPending).toBe(false);
+    expect(debouncer.call("hello")).toBeUndefined();
+    expect(debouncer.isPending).toBe(true);
+
+    await sleep(1);
+
+    expect(debouncer.isPending).toBe(true);
+
+    await sleep(32);
+
+    expect(debouncer.isPending).toBe(false);
+  });
+
+  it("can check for inflight timers (leading)", async () => {
+    const debouncer = debounce(identity(), { timing: "leading", waitMs: 32 });
+
+    expect(debouncer.isPending).toBe(false);
+    expect(debouncer.call("hello")).toBe("hello");
+    expect(debouncer.isPending).toBe(true);
+
+    await sleep(1);
+
+    expect(debouncer.isPending).toBe(true);
+
+    await sleep(32);
+
+    expect(debouncer.isPending).toBe(false);
+  });
+
+  it("can flush before a cool-down", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+
+    expect(debouncer.flush()).toBeUndefined();
+    expect(debouncer.call("hello")).toBeUndefined();
+
+    await sleep(32);
+
+    expect(debouncer.call("world")).toBe("hello");
+  });
+
+  it("can flush during a cool-down", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+
+    expect(debouncer.call("hello")).toBeUndefined();
+
+    await sleep(1);
+
+    expect(debouncer.call("world")).toBeUndefined();
+
+    await sleep(1);
+
+    expect(debouncer.flush()).toBe("world");
+  });
+
+  it("can flush after a cool-down", async () => {
+    const debouncer = debounce(identity(), { waitMs: 32 });
+
+    expect(debouncer.call("hello")).toBeUndefined();
+
+    await sleep(32);
+
+    expect(debouncer.flush()).toBe("hello");
+  });
+});
+
+describe("errors", () => {
+  it("prevents maxWaitMs to be less then waitMs", () => {
+    expect(() => debounce(identity(), { waitMs: 32, maxWaitMs: 16 })).toThrow(
+      "debounce: maxWaitMs (16) cannot be less than waitMs (32)",
+    );
+  });
+});
diff --git a/src/funnel.test-d.ts b/src/funnel.test-d.ts
new file mode 100644
index 0000000..e876297
--- /dev/null
+++ b/src/funnel.test-d.ts
@@ -0,0 +1,120 @@
+/* eslint-disable @typescript-eslint/no-unused-vars -- We just want to build types, we don't care about using the params... */
+
+import { doNothing } from "./doNothing";
+import { funnel } from "./funnel";
+
+describe("'call' method args", () => {
+  test("no args", () => {
+    const foo = funnel(doNothing(), {
+      reducer: (_: "test" | undefined) => "test" as const,
+      triggerAt: "start",
+    });
+    expectTypeOf(foo.call).parameters.toEqualTypeOf<[]>();
+  });
+
+  test("non-optional args", () => {
+    const foo = funnel(doNothing(), {
+      // @ts-expect-error [ts(6133)] -- We want to use explicit names, not prefixed with _
+      reducer: (_: "test" | undefined, a: string, b: number, c: boolean) =>
+        "test" as const,
+
+      triggerAt: "start",
+    });
+    expectTypeOf(foo.call).parameters.toEqualTypeOf<
+      [a: string, b: number, c: boolean]
+    >();
+  });
+
+  test("optional args", () => {
+    const foo = funnel(doNothing(), {
+      // @ts-expect-error [ts(6133)] -- We want to use explicit names, not prefixed with _
+      reducer: (_: "test" | undefined, a?: string) => "test" as const,
+      triggerAt: "start",
+    });
+    expectTypeOf(foo.call).parameters.toEqualTypeOf<[a?: string | undefined]>();
+  });
+
+  test("rest args", () => {
+    const foo = funnel(doNothing(), {
+      reducer:
+        // @ts-expect-error [ts(6133)] -- We want to use explicit names, not prefixed with _
+        // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types -- rest params can't be readonly, it breaks typing :(
+        (_: "test" | undefined, ...as: Array<string>) => "test" as const,
+
+      triggerAt: "start",
+    });
+    expectTypeOf(foo.call).parameters.toEqualTypeOf<Array<string>>();
+  });
+});
+
+describe("derive the reducer accumulator type from the executor param", () => {
+  test("simple types", () => {
+    funnel(
+      (_: number) => {
+        // do nothing
+      },
+      {
+        reducer: (reduced) => {
+          expectTypeOf(reduced).toEqualTypeOf<number | undefined>();
+          return reduced!;
+        },
+        triggerAt: "start",
+      },
+    );
+  });
+
+  test("arrays", () => {
+    funnel(
+      (_: ReadonlyArray<number>) => {
+        // do nothing,
+      },
+      {
+        reducer: (reduced) => {
+          expectTypeOf(reduced).toEqualTypeOf<
+            ReadonlyArray<number> | undefined
+          >();
+          return reduced!;
+        },
+        triggerAt: "start",
+      },
+    );
+  });
+
+  test("objects", () => {
+    funnel(
+      (_: { readonly a: number }) => {
+        // do nothing
+      },
+      {
+        reducer: (reduced) => {
+          expectTypeOf(reduced).toEqualTypeOf<
+            { readonly a: number } | undefined
+          >();
+          return reduced!;
+        },
+        triggerAt: "start",
+      },
+    );
+  });
+});
+
+describe("prevent bad options", () => {
+  test("minGapMs cannot be the only option with timing: end", () => {
+    funnel(
+      doNothing(),
+      // @ts-expect-error [ts(2345)] -- minGapMs cannot be set alone]
+      { minGapMs: 100 },
+    );
+
+    funnel(
+      doNothing(),
+      // @ts-expect-error [ts(2345)] -- minGapMs cannot be set alone]
+      { triggerAt: "end", minGapMs: 100 },
+    );
+
+    // But it works with "start" and "both"
+
+    funnel(doNothing(), { triggerAt: "start", minGapMs: 100 });
+    funnel(doNothing(), { triggerAt: "both", minGapMs: 100 });
+  });
+});
diff --git a/src/funnel.test.ts b/src/funnel.test.ts
new file mode 100644
index 0000000..59e3fe7
--- /dev/null
+++ b/src/funnel.test.ts
@@ -0,0 +1,998 @@
+import { sleep } from "../test/sleep";
+import { constant } from "./constant";
+import { doNothing } from "./doNothing";
+import { funnel } from "./funnel";
+
+// We need some non-trivial duration to use in all our tests, to abstract the
+// actual chosen value we use this UnitOfTime (UT) constant. As long as it is a
+// positive integer, the actual value doesn't matter (but the larger it is,
+// the longer the tests would take to run). The number is in milliseconds.
+const UT = 16;
+
+// We use this reducer to collect arguments from each invocation so we can test
+// what the function was invoked with.
+const ARGS_COLLECTOR = (
+  accumulator: ReadonlyArray<string> | undefined,
+  item: string,
+): ReadonlyArray<string> =>
+  accumulator === undefined ? [item] : [...accumulator, item];
+
+describe("reducer behavior", () => {
+  it("passes the reduced arg to the executor", () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: constant("hello world"),
+      triggerAt: "start",
+      minQuietPeriodMs: UT,
+    });
+    foo.call();
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith("hello world");
+  });
+
+  it("reduces call args", async () => {
+    const mockFn = vi.fn((_total: number): void => {
+      /* do nothing */
+    });
+
+    const foo = funnel(mockFn, {
+      reducer: (total, item: number) => (total ?? 0) + item,
+      minQuietPeriodMs: 2 * UT,
+      triggerAt: "end",
+    });
+    foo.call(1);
+    foo.call(2);
+    foo.call(3);
+    await sleep(UT);
+    foo.call(4);
+    foo.call(5);
+    foo.call(6);
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(21 /* 1 + 2 + ... + 6 */);
+  });
+
+  it("does not invoke if reduceArgs returns undefined", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: constant(undefined),
+      triggerAt: "end",
+      minQuietPeriodMs: UT,
+    });
+    foo.call();
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(0);
+  });
+
+  it("supports multiple arguments", () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: (ret: unknown, a: number, b: string, c: boolean) => [
+        ret,
+        a,
+        b,
+        c,
+      ],
+      triggerAt: "start",
+      minQuietPeriodMs: UT,
+    });
+    foo.call(1, "a", true);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith([undefined, 1, "a", true]);
+  });
+
+  test("reducer isn't called again when 'invokedAt: start'", async () => {
+    const mockFn = vi.fn(ARGS_COLLECTOR);
+    const foo = funnel(doNothing(), {
+      reducer: mockFn,
+      triggerAt: "start",
+      minQuietPeriodMs: UT,
+    });
+    foo.call("a");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(undefined, "a");
+
+    foo.call("b");
+    foo.call("c");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(2 * UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("non-trivial (>0ms) timer duration", () => {
+  describe("delay timer", () => {
+    test("invokedAt: start", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "start",
+        minGapMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      foo.call("d");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      // "b" and "c" have been ignored because the funnel is only configured to
+      // run the function at the start of the delay window, meaning anything
+      // after that is simply dropped.
+      expect(mockFn).toHaveBeenLastCalledWith(["d"]);
+    });
+
+    test("invokedAt: both", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minGapMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c"]);
+
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(3);
+      expect(mockFn).toHaveBeenLastCalledWith(["d", "e", "f"]);
+    });
+
+    test("invocations in the middle of a window", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minGapMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(0.75 * UT);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(0.75 * UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c", "d", "e", "f"]);
+    });
+  });
+
+  describe("burst timer", () => {
+    test("invokedAt: start", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "start",
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      foo.call("d");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      // "b" and "c" have been ignored because the funnel is only configured to
+      // run the function at the start of the delay window, meaning anything
+      // after that is simply dropped.
+      expect(mockFn).toHaveBeenLastCalledWith(["d"]);
+    });
+
+    test("invokedAt: both", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c"]);
+
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(3);
+      expect(mockFn).toHaveBeenLastCalledWith(["d"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(4);
+      expect(mockFn).toHaveBeenLastCalledWith(["e", "f"]);
+    });
+
+    test("invokedAt: end", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c"]);
+
+      foo.call("e");
+      foo.call("f");
+      foo.call("g");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["e", "f", "g"]);
+    });
+
+    test("invocations in the middle of a window", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      await sleep(0.9 * UT);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      await sleep(0.1 * UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c", "d", "e", "f"]);
+    });
+
+    test("maxBurstDurationMs limits the burst duration", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        minQuietPeriodMs: UT,
+        maxBurstDurationMs: 2 * UT,
+        triggerAt: "end",
+      });
+      foo.call("a");
+      await sleep(0.75 * UT);
+      foo.call("b");
+      await sleep(0.75 * UT);
+      foo.call("c");
+
+      // Total time is approximately 1.5*UT, less than 2UT which is
+      // maxBurstDurationMs
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      // We sleep less than minQuietPeriodMs but more than our
+      // maxBurstDurationMs so that we validate that the burst is being ended
+      // prematurely.
+      await sleep(0.75 * UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c"]);
+    });
+  });
+
+  describe("both timers", () => {
+    test("delay is longer than burst", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        minGapMs: 2 * UT,
+        minQuietPeriodMs: UT,
+        triggerAt: "both",
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c", "d", "e", "f"]);
+    });
+
+    test("burst is longer than delay", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        minGapMs: UT,
+        minQuietPeriodMs: 2 * UT,
+        triggerAt: "both",
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c", "d", "e", "f"]);
+    });
+
+    test("burst and delay are equal", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        minGapMs: UT,
+        minQuietPeriodMs: UT,
+        triggerAt: "both",
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(3);
+      expect(mockFn).toHaveBeenLastCalledWith(["d", "e", "f"]);
+    });
+
+    test("delay and maxBurstDurationMs are equal", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        minGapMs: UT,
+        minQuietPeriodMs: 2 * UT,
+        maxBurstDurationMs: UT,
+        triggerAt: "both",
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(UT);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(3);
+      expect(mockFn).toHaveBeenLastCalledWith(["d", "e", "f"]);
+    });
+  });
+});
+
+describe("immediate (===0) timer durations", () => {
+  describe("delay timer", () => {
+    test("invokedAt: start", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "start",
+        minGapMs: 0,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(0);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      // "b" and "c" have been ignored because the funnel is only configured to
+      // run the function at the start of the delay window, meaning anything
+      // after that is simply dropped.
+      expect(mockFn).toHaveBeenLastCalledWith(["d"]);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+    });
+
+    test("invokedAt: both", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minGapMs: 0,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(0);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c"]);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(3);
+      expect(mockFn).toHaveBeenLastCalledWith(["d", "e", "f"]);
+    });
+
+    test("with a non-trivial burst timer", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minGapMs: 0,
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      foo.call("b");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b"]);
+    });
+  });
+
+  describe("burst timer", () => {
+    test("invokedAt: start", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "start",
+        minQuietPeriodMs: 0,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(0);
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      // "b" and "c" have been ignored because the funnel is only configured to
+      // run the function at the start of the delay window, meaning anything
+      // after that is simply dropped.
+      expect(mockFn).toHaveBeenLastCalledWith(["d"]);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+    });
+
+    test("invokedAt: both", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minQuietPeriodMs: 0,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b", "c"]);
+
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(3);
+      expect(mockFn).toHaveBeenLastCalledWith(["d"]);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(4);
+      expect(mockFn).toHaveBeenLastCalledWith(["e", "f"]);
+    });
+
+    test("invokedAt: end", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: 0,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c"]);
+
+      foo.call("d");
+      foo.call("e");
+      foo.call("f");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["d", "e", "f"]);
+    });
+
+    test("with a non-trivial delay timer", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "both",
+        minGapMs: UT,
+        minQuietPeriodMs: 0,
+      });
+      foo.call("a");
+      foo.call("b");
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(2);
+      expect(mockFn).toHaveBeenLastCalledWith(["b"]);
+    });
+
+    test("burst timer with non-trivial maxBurstDuration", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        minQuietPeriodMs: 0,
+        maxBurstDurationMs: UT,
+        triggerAt: "end",
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+
+      await sleep(0);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c"]);
+    });
+  });
+
+  test("both timers", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "both",
+      minGapMs: 0,
+      minQuietPeriodMs: 0,
+    });
+    foo.call("a");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+    await sleep(0);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+  });
+
+  test("maxBurstDurationMs = 0 limits the burst immediately", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "end",
+      minQuietPeriodMs: UT,
+      maxBurstDurationMs: 0,
+    });
+    foo.call("a");
+    await sleep(0);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+  });
+
+  test("all timeouts zero", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "both",
+      minGapMs: 0,
+      minQuietPeriodMs: 0,
+      maxBurstDurationMs: 0,
+    });
+    foo.call("a");
+    foo.call("b");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+    await sleep(0);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+    expect(mockFn).toHaveBeenLastCalledWith(["b"]);
+  });
+});
+
+describe("default minQuietPeriodMs === 0 when minGapMs is not defined", () => {
+  test("invokedAt: start", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "start",
+    });
+    foo.call("a");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+    foo.call("b");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(0);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    foo.call("c");
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+    expect(mockFn).toHaveBeenLastCalledWith(["c"]);
+  });
+
+  test("invokedAt: both", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "both",
+    });
+    foo.call("a");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+    foo.call("b");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(0);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+    expect(mockFn).toHaveBeenLastCalledWith(["b"]);
+
+    foo.call("c");
+
+    expect(mockFn).toHaveBeenCalledTimes(3);
+    expect(mockFn).toHaveBeenLastCalledWith(["c"]);
+  });
+});
+
+describe("utility functions", () => {
+  describe("flush", () => {
+    test("flush triggers an immediate invocation", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+      foo.flush();
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c"]);
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+    });
+
+    test("flush during active burst", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: 2 * UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      await sleep(UT);
+
+      foo.flush();
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+      expect(mockFn).toHaveBeenLastCalledWith(["a", "b", "c"]);
+
+      await sleep(2 * UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe("cancel", () => {
+    test("cancel prevents invocation", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: UT,
+      });
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+      foo.cancel();
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(0);
+    });
+
+    test("cancel during delay period", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "start",
+        minGapMs: 2 * UT,
+      });
+      foo.call("a");
+      foo.call("b");
+
+      await sleep(UT);
+      foo.cancel();
+
+      await sleep(UT);
+
+      expect(mockFn).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe("isIdle", () => {
+    test("isIdle reflects the funnel's state", () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: UT,
+      });
+
+      expect(foo.isIdle).toBe(true);
+
+      foo.call("a");
+
+      expect(foo.isIdle).toBe(false);
+
+      foo.cancel();
+
+      expect(foo.isIdle).toBe(true);
+    });
+
+    test("isIdle works when burst duration is 0", async () => {
+      const mockFn = vi.fn();
+      const foo = funnel(mockFn, {
+        reducer: ARGS_COLLECTOR,
+        triggerAt: "end",
+        minQuietPeriodMs: 0,
+      });
+
+      expect(foo.isIdle).toBe(true);
+
+      foo.call("a");
+      foo.call("b");
+      foo.call("c");
+
+      expect(foo.isIdle).toBe(false);
+
+      await sleep(0);
+
+      expect(foo.isIdle).toBe(true);
+    });
+  });
+});
+
+describe("edge-cases", () => {
+  test("bursts that start late don't prevent delayed invocations", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "both",
+      minQuietPeriodMs: UT,
+      minGapMs: 2 * UT,
+    });
+    foo.call("a");
+    foo.call("b");
+    foo.call("c");
+
+    // We expect the function to be called once because we invoke it both at
+    // the start and end of the blackout periods.
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+    await sleep(1.5 * UT);
+
+    // But because our delay time is still longer, we still expect that the
+    // function hasn't been called yet again for the end of the blackout period.
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    foo.call("d");
+    foo.call("e");
+    foo.call("f");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(0.5 * UT);
+
+    // After the delay is over we expect the function to be invoked again for
+    // the end of the blackout period.
+    expect(mockFn).toHaveBeenCalledTimes(2);
+    expect(mockFn).toHaveBeenLastCalledWith(["b", "c", "d", "e", "f"]);
+  });
+
+  test("delay timeouts don't cause an invocation in the middle of bursts", async () => {
+    const mockFn = vi.fn();
+    const foo = funnel(mockFn, {
+      reducer: ARGS_COLLECTOR,
+      triggerAt: "both",
+      minQuietPeriodMs: 2 * UT,
+      minGapMs: UT,
+    });
+    foo.call("a");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+    expect(mockFn).toHaveBeenLastCalledWith(["a"]);
+
+    await sleep(UT);
+    foo.call("b");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(UT);
+    foo.call("c");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(UT);
+    foo.call("d");
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(1);
+
+    await sleep(UT);
+
+    expect(mockFn).toHaveBeenCalledTimes(2);
+  });
+});
diff --git a/test/sleep.ts b/test/sleep.ts
new file mode 100644
index 0000000..e8614ed
--- /dev/null
+++ b/test/sleep.ts
@@ -0,0 +1,15 @@
+/**
+ * Asynchronously sleep. This allows tests to **actually** wait for a specific
+ * amount of time. Use this extremely sparingly as it will cause those tests to
+ * take **much** longer to run, and would cause our complete test suite to take
+ * **much** longer to run, too.
+ *
+ * @param ms - The number of milliseconds to sleep.
+ * @example
+ *   await sleep(1000); // Wait for 1 second.
+ */
+export async function sleep(ms: number): Promise<void> {
+  await new Promise((resolve) => {
+    setTimeout(resolve, ms);
+  });
+}