es-toolkit icon indicating copy to clipboard operation
es-toolkit copied to clipboard

Support for `throttleAsync` function

Open cmg8431 opened this issue 8 months ago • 0 comments

What if we added a throttleAsync function to throttle asynchronous functions?

Suggesting

I suggest implementing a promise-based throttling function that only allows a new invocation when the previous one has completed.

export function throttleAsync<F extends (...args: any[]) => Promise<any>>(
  func: F,
  { signal, throwOnCancel = false }: ThrottleAsyncOptions = {}
): ((...args: Parameters<F>) => Promise<ReturnType<F> | null>) & { cancel: () => void } {
  let isRunning = false;
  let isCancelled = false;
  
  if (signal) {
    signal.addEventListener('abort', () => {
      isCancelled = true;
    }, { once: true });
  }
  
  function cancel(): void {
    isCancelled = true;
  }
  
  const throttled = async function(...args: Parameters<F>): Promise<ReturnType<F> | null> {
    if (isCancelled) {
      if (throwOnCancel) {
        throw new Error('Operation cancelled');
      }
      return null;
    }
    
    if (isRunning) {
      return null;
    }
    
    isRunning = true;
    
    try {
      const result = await func(...args);
      return result;
    } finally {
      isRunning = false;
    }
  } as ((...args: Parameters<F>) => Promise<ReturnType<F> | null>) & { cancel: () => void };
  
  throttled.cancel = cancel;
  
  return throttled;
}

Why this is needed

The current throttle function is time-based, which works well for UI events but has limitations with asynchronous operations. Consider this common scenario:

// Current approach with time-based throttle
const throttledFetch = throttle(fetchData, 1000);

throttledFetch('/api/users'); // Executes and takes 2 seconds to complete
// 1.5 seconds later
throttledFetch('/api/users'); // Executes again because 1 second has passed
                              // But the first request hasn't finished yet!

This can lead to:

  1. Multiple simultaneous API calls
  2. Race conditions
  3. Duplicate form submissions

With the proposed throttleAsync:

// Using promise-based throttleAsync
const throttledFetch = throttleAsync(fetchData);

throttledFetch('/api/users'); // Executes and takes 2 seconds
throttledFetch('/api/users'); // Ignored because previous call is still in progress
// 2 seconds later when first call completes
throttledFetch('/api/users'); // Now allowed to execute

Real-world examples

Preventing duplicate form submissions

const form = document.querySelector('form');
const submitForm = async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  return fetch('/api/submit', { method: 'POST', body: data });
};

// Prevent multiple submissions while one is in progress
const throttledSubmit = throttleAsync(submitForm);
form.addEventListener('submit', throttledSubmit);

Avoiding duplicate API calls

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  // Create throttled fetch that prevents duplicate calls
  const fetchUserData = useCallback(
    throttleAsync(async (id) => {
      const response = await fetch(`/api/users/${id}`);
      const data = await response.json();
      setUser(data);
    }),
    []
  );
  
  // Even if this effect runs multiple times in quick succession,
  // only one API call will be made at a time
  useEffect(() => {
    fetchUserData(userId);
  }, [userId, fetchUserData]);
}

I think this would be a useful addition to complement the existing throttle function, as it addresses a different but common use case in modern web applications.

References

cmg8431 avatar Mar 07 '25 09:03 cmg8431