{ ILoveJS }

Debounce for async functions

typescript

A production-ready debounce utility specifically designed for async functions. It cancels previous pending calls and always resolves with the result of the most recent invocation.

debounceasyncperformance

Code

typescript
type AsyncFunction<TArgs extends unknown[], TReturn> = (...args: TArgs) => Promise<TReturn>;

interface DebouncedFunction<TArgs extends unknown[], TReturn> {
  (...args: TArgs): Promise<TReturn>;
  cancel: () => void;
  flush: (...args: TArgs) => Promise<TReturn>;
  pending: () => boolean;
}

interface PendingCall<TReturn> {
  resolve: (value: TReturn) => void;
  reject: (reason: unknown) => void;
}

function debounceAsync<TArgs extends unknown[], TReturn>(
  fn: AsyncFunction<TArgs, TReturn>,
  delay: number
): DebouncedFunction<TArgs, TReturn> {
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
  let pendingCalls: PendingCall<TReturn>[] = [];
  let lastArgs: TArgs | null = null;
  let activePromise: Promise<TReturn> | null = null;

  const executeFunction = async (args: TArgs): Promise<TReturn> => {
    const currentPendingCalls = [...pendingCalls];
    pendingCalls = [];
    activePromise = fn(...args);

    try {
      const result = await activePromise;
      currentPendingCalls.forEach(({ resolve }) => resolve(result));
      return result;
    } catch (error) {
      currentPendingCalls.forEach(({ reject }) => reject(error));
      throw error;
    } finally {
      activePromise = null;
    }
  };

  const debouncedFn = (...args: TArgs): Promise<TReturn> => {
    lastArgs = args;

    if (timeoutId !== null) {
      clearTimeout(timeoutId);
    }

    return new Promise<TReturn>((resolve, reject) => {
      pendingCalls.push({ resolve, reject });

      timeoutId = setTimeout(() => {
        timeoutId = null;
        if (lastArgs !== null) {
          executeFunction(lastArgs).catch(() => {});
        }
      }, delay);
    });
  };

  debouncedFn.cancel = (): void => {
    if (timeoutId !== null) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
    const cancelError = new Error('Debounced function cancelled');
    pendingCalls.forEach(({ reject }) => reject(cancelError));
    pendingCalls = [];
    lastArgs = null;
  };

  debouncedFn.flush = async (...args: TArgs): Promise<TReturn> => {
    if (timeoutId !== null) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
    
    const argsToUse = args.length > 0 ? args : lastArgs;
    
    if (argsToUse === null) {
      throw new Error('No arguments to flush');
    }
    
    return executeFunction(argsToUse);
  };

  debouncedFn.pending = (): boolean => {
    return timeoutId !== null || activePromise !== null;
  };

  return debouncedFn;
}

// Example usage with a search API
const searchAPI = async (query: string): Promise<string[]> => {
  console.log(`API called with: "${query}"`);
  await new Promise(resolve => setTimeout(resolve, 100));
  return [`Result 1 for ${query}`, `Result 2 for ${query}`];
};

const debouncedSearch = debounceAsync(searchAPI, 300);

// Simulate rapid typing
async function demo() {
  const promise1 = debouncedSearch('h');
  const promise2 = debouncedSearch('he');
  const promise3 = debouncedSearch('hel');
  const promise4 = debouncedSearch('help');

  // All promises resolve with the same result (from 'help')
  const results = await Promise.all([promise1, promise2, promise3, promise4]);
  console.log('All results:', results);
  console.log('Pending:', debouncedSearch.pending());
}

demo();

How It Works

This debounce utility solves a common problem with async functions: when you debounce an async function using traditional debounce implementations, you lose the ability to await the result or handle errors properly. This implementation returns a promise from every call that resolves with the result of the actual execution.

The core mechanism works by maintaining an array of pending promise resolvers. Each time the debounced function is called, it clears any existing timeout, stores the new arguments, and adds a new promise's resolve/reject functions to the pending array. When the delay expires, the original async function executes with the most recent arguments, and all accumulated promises resolve or reject with the same outcome. This means if you call the function 10 times rapidly, you get 10 promises back, but only one actual API call happens, and all 10 promises resolve with that single result.

The implementation includes three utility methods on the debounced function. The cancel() method clears any pending timeout and rejects all waiting promises with a cancellation error — useful for cleanup in React useEffect or when a component unmounts. The flush() method immediately executes the function with either provided arguments or the last stored arguments, bypassing the delay entirely. The pending() method returns whether there's a scheduled execution or an active promise, helpful for showing loading states.

One key design decision is handling the relationship between pending calls and active executions. If a new call comes in while a previous execution is still running, it starts a new debounce cycle. This prevents race conditions where a slow first call might resolve after a faster second call. The activePromise tracking ensures the pending() method accurately reflects whether any work is in progress.

Use this pattern for search-as-you-type features, form auto-save, window resize handlers that trigger API calls, or any scenario where rapid user input triggers expensive async operations. Avoid it when you need every call to execute (use throttle instead) or when the order of calls matters (consider a queue). The delay should be tuned based on your use case — 150-300ms for search, longer for auto-save.