npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

debounce-merge

v0.2.0

Published

Debounce function calls per object id, merging accumulated arguments until the timeout fires

Readme

debounce-merge

Debounce calls to a function per object id, deep-merging the arguments of every call that happens inside the debounce window. Only the last call in a window actually runs but it runs with the merged arguments of everyone who called during that window, and everyone gets the same result back.

Why

Say you have a function that saves a user to the server:

function saveUser(user: { id: string }, patch: Partial<User>) {
  return api.patch(`/users/${user.id}`, patch);
}
  1. If several parts of your UI call saveUser for the same user in quick succession (e.g. while the user is typing across a few fields), you usually don't want to fire a request per keystroke, and you don't want the second request to blow away fields the first one just set. createDeferred gives you both: calls for the same user.id are batched into one request, with the patches merged together.

Calls for a different user.id are completely independent - they get their own timer and don't interfere with each other.

  1. The React Unmount Problem (Global Safety) A very common issue in React is that if you declare a debounced save function inside a component (or use a standard hook), the timer is tied to the component's lifecycle. If the user makes an edit and immediately navigates away or selects a different object on the screen, the component unmounts, the timer gets destroyed, and the final save is lost.

With debounce-merge, you declare your debounced function globally, outside of the React component tree. Because it automatically routes and isolates timers based on the id, you don't need to instantiate a new debouncer per component. When a component unmounts, the global timer for that id keeps ticking safely in the background, guaranteeing your data is saved.

Install

npm install debounce-merge

Quick example

import { createDeferred } from "debounce-merge";

type User = { id: string; name: string; age: number };

function saveUser(user: { id: string }, patch: Partial<User>) {
  console.log("saving", user.id, patch);
  return api.patch(`/users/${user.id}`, patch);
}

const deferredSaveUser = createDeferred(saveUser, 300); // 300ms window

deferredSaveUser({ id: "u1" }, { name: "Alice" });
deferredSaveUser({ id: "u1" }, { age: 30 });

// 300ms later, exactly ONE call happens:
// saving u1 { name: "Alice", age: 30 }

// Both calls above resolve with the same result, once that one call finishes.

Calls for a different id run independently and are not merged with the above:

deferredSaveUser({ id: "u2" }, { name: "Bob" }); // separate timer, separate call

How it works

  1. Groups by id - Calls sharing the same object id are batched independently (or pooled into a shared batch if no id is present).
  2. Deep-merges arguments - All arguments passed within the debounce window are combined into a single set.
  3. Executes once - When the timer fires, fn runs once with the merged payload and returns the result to all callers in that batch.
call A ──┐
call B ──┼─ merged args -> (timer fires) -> fn(merged) -> same result to A, B, C
call C ──┘

API

createDeferred(fn, timeoutMs?)

function createDeferred<TFunc extends (...args: any[]) => any>(
  fn: TFunc,
  timeoutMs?: number, // default: 300
): (...args: Parameters<TFunc>) => Promise<ReturnType<TFunc>>;
  • fn - the function to debounce. Its parameters and return type are picked up automatically - the function returned by createDeferred has the exact same parameter types as fn, and resolves to fn's return type.
  • timeoutMs - debounce window in milliseconds. Defaults to 300.

Returns a function you call exactly like fn, except it returns a Promise.

Pivoting rules

| First argument of fn | Pivot used | | --------------------------- | ------------------------------------ | | { id: "abc", ... } | "abc" - batched with other calls sharing this id | | { id: 42, ... } | 42 - same rule, numeric ids work too | | anything else (or no args) | "common" - all such calls share one batch |

Exported types

import type { CreateDeferred, ObjectWithId, Id } from "debounce-merge";
import { AbortError } from "debounce-merge";
  • Id - string | number. The type accepted for an object's id field.
  • ObjectWithId - { id: Id }. Shape checked on the first argument to decide the pivot.
  • CreateDeferred<TArgs, TReturn> - the type of the function returned by createDeferred, in case you want to store it in a typed variable:
const deferredSaveUser: CreateDeferred<[user: { id: string }, patch: Partial<User>], Response> =
  createDeferred(saveUser, 300);
  • AbortError - the error class a pending call's promise rejects with when it's discarded via cancelPendingChangesForPivot(). Extends Error. See that section for details.

applyAllPendingChanges()

function applyAllPendingChanges(): Promise<PromiseSettledResult<unknown>[]>;

Immediately flushes every pending debounced call - across every pivot and every debounced function - instead of waiting for its timeoutMs window to elapse. Useful for moments where you can't afford to wait, e.g. before the page unloads, on manual "Save now", or when unmounting the whole app.

For each pending call, it:

  1. Cancels the scheduled timer, so it won't fire again later.
  2. Calls the original function right away, with the merged arguments accumulated so far.
  3. Resolves or rejects the exact same promise already handed back by createDeferred. If the function is async and returns a Promise, that result is unwrapped automatically - callers get the final value

The returned promise resolves via Promise.allSettled. If nothing is pending, it resolves to [].

const deferredSaveUser = createDeferred(saveUser, 5000); // 5s window

deferredSaveUser({ id: "u1" }, { name: "Alice" });
deferredSaveUser({ id: "u2" }, { name: "Bob" });

// User is closing the tab - can't wait 5 seconds:
const results = await applyAllPendingChanges();
// [{ status: "fulfilled", value: ... }, { status: "fulfilled", value: ... }]

Note: this is a flush, not a cancel - pending calls are run, not discarded. The order of entries in the returned array is not guaranteed to match the order calls were originally made in.

Internally, this just loops over every pivot currently pending and calls applyPendingChangesForPivot() on each one - if you only need to flush a single pivot, use that instead.

applyPendingChangesForPivot(pivotId)

function applyPendingChangesForPivot(
  pivotId: Id,
): Promise<PromiseSettledResult<unknown>[]>;

The same flush behavior as applyAllPendingChanges(), scoped to a single pivot. Every debounced function that has a pending call for that pivotId is flushed immediately; every other pivot is left untouched, still waiting out its own timer.

Useful when you know exactly which record you're done editing and want to save it right away, without forcing every other unrelated pending save in your app to fire early too.

const deferredSaveUser = createDeferred(saveUser, 5000); // 5s window

deferredSaveUser({ id: "u1" }, { name: "Alice" });
deferredSaveUser({ id: "u2" }, { name: "Bob" });

// User just finished editing u1 and moved on - flush only their save.
// u2's pending call is untouched and still has up to 5s left on its timer.
const results = await applyPendingChangesForPivot("u1");
// [{ status: "fulfilled", value: ... }]

If the given pivotId has nothing pending, it resolves to [] - same as applyAllPendingChanges() does when the whole store is empty.

cancelPendingChangesForPivot(pivotId)

function cancelPendingChangesForPivot(pivotId: Id): void;

Discards every pending debounced call for a single pivot, without ever calling the underlying function. This is the opposite of applyPendingChangesForPivot(): that one runs what's pending, this one throws it away.

For each pending call tied to pivotId, it:

  1. Cancels the scheduled timer, so it won't fire later.
  2. Rejects the exact same promise already handed back by createDeferred, with an AbortError.
  3. Clears the accumulated (merged) arguments for that pivot - they are gone, not carried over to the next call.

Other pivots are completely unaffected and keep running on their own timers.

const deferredSaveUser = createDeferred(saveUser, 5000); // 5s window

const savePromise = deferredSaveUser({ id: "u1" }, { name: "Alice" });

// User discarded their edits before the debounce window elapsed -
// don't save anything for u1.
cancelPendingChangesForPivot("u1");

await savePromise; // rejects with AbortError

If the given pivotId has nothing pending (including if it was already flushed or cancelled), this is a no-op.

Note: unlike applyAllPendingChanges(), there is currently no cancelAllPendingChanges() helper that cancels every pivot at once - open an issue if you need one.

AbortError

The error class every promise rejects with when its pivot is cancelled via cancelPendingChangesForPivot().

class AbortError extends Error {
  readonly pivotId: Id;
  readonly functionId: number;
}
  • name is "AbortError".
  • pivotId is the pivot that was cancelled.
  • functionId is an internal id identifying which debounced function (as returned by createDeferred) the rejected call belonged to.
try {
  await deferredSaveUser({ id: "u1" }, { name: "Alice" });
} catch (err) {
  if (err instanceof AbortError) {
    console.log(`save for ${err.pivotId} was cancelled`);
  } else {
    throw err;
  }
}

Using with React (or any component framework)

createDeferred must be created once and reused - not recreated on every render. The pending-change store (changeStore) lives at module scope and is keyed by an internal id that's generated the moment createDeferred runs, not on every call. If you call createDeferred inside a component body, every re-render produces a brand new internal id, so calls made before and after that re-render land in different, unrelated buckets and never merge - silently defeating the whole point of this library, without throwing any error.

// Bad - a new debounced function (and a new internal id) is created
// on every render. Keystrokes before and after a re-render never merge.
function UserForm({ user }: { user: User }) {
  const deferredSave = createDeferred(saveUser, 300);

  return (
    <input onChange={(e) => deferredSave(user, { name: e.target.value })} />
  );
}
// Good - created once, outside any component. Since batching is already
// keyed by the object's `id`, one shared instance safely handles every
// user, in every component, at once.
// userService.ts
export const deferredSaveUser = createDeferred(saveUser, 300);

// UserForm.tsx
function UserForm({ user }: { user: User }) {
  return (
    <input onChange={(e) => deferredSaveUser(user, { name: e.target.value })} />
  );
}
// Also fine - scoped to one component instance via useRef/useMemo,
// if you specifically don't want to share it across the whole app.
function UserForm({ user }: { user: User }) {
  const deferredSave = useMemo(() => createDeferred(saveUser, 300), []);

  return (
    <input
      onChange={(e) => deferredSave(user, { name: e.target.value })}
    />
  );
}

A pending call also outlives the component that triggered it. The setTimeout behind it is a plain runtime timer, completely outside React's lifecycle - unmounting a component does not cancel it. If your .then() touches component-local state, guard it, or better, keep fn itself side-effect-only against an external store/API rather than a local setter:

useEffect(() => {
  let isMounted = true;

  deferredSaveUser(user, patch).then((res) => {
    if (isMounted) setSavedState(res);
  });

  return () => {
    isMounted = false;
  };
}, [patch]);

If instead you do want in-flight edits for one specific record dropped the moment the user navigates away from it, pair this with cancelPendingChangesForPivot() in your cleanup:

useEffect(() => {
  return () => {
    cancelPendingChangesForPivot(user.id);
  };
}, [user.id]);

Behavior notes

  • All calls in a window share one result. If three calls happen inside the same window, all three promises resolve together, with the same value - fn genuinely only runs once.
  • Merging is deep, via deepmerge. Arrays and nested objects in your arguments get merged, not replaced - check deepmerge's docs if you need custom array-merge behavior.
  • A new call after the window has fired starts a fresh cycle - it does not merge with the previous (already completed) batch. The same is true after a cancel: a new call for that pivot starts a fresh cycle too, with no memory of the discarded arguments.
  • Need to flush everything immediately instead of waiting out the debounce window? See applyAllPendingChanges() (every pivot) or applyPendingChangesForPivot() (a single pivot).
  • Need to discard pending changes instead of running them? See cancelPendingChangesForPivot().
  • Works in both Node.js (≥18) and browsers.

License

MIT