DEV Community

Samuel Ochaba
Samuel Ochaba

Posted on

Beyond Basic Debouncing: What I Learned Building Search for a Logistics App

Last sprint, I shipped what I thought was a simple feature: debounced search for our logistics dashboard. Users needed to search through thousands of shipments without hammering our API on every keystroke. Easy, right? Just slap a 300ms debounce on the input and call it a day.

It was not that simple.

The Problem with "Simple" Debouncing

Our initial implementation looked like every tutorial out there:

const debouncedSearch = debounce((query) => {
  fetchShipments(query);
}, 300);
Enter fullscreen mode Exit fullscreen mode

It worked! Until it didn't. Our support team started getting complaints:

  • "The search feels laggy"
  • "Sometimes my searches don't fire at all"
  • "When I'm on slow internet, nothing happens"

Time to level up.

Advanced Pattern #1: Leading Edge Debouncing

Standard debouncing waits until the user stops typing. But what if we want immediate feedback, then throttle subsequent calls?

function leadingDebounce(func, delay) {
  let timeoutId;
  let lastCallTime = 0;

  return function(...args) {
    const now = Date.now();
    const timeSinceLastCall = now - lastCallTime;

    clearTimeout(timeoutId);

    // Fire immediately on first call or after delay period
    if (timeSinceLastCall >= delay) {
      lastCallTime = now;
      func.apply(this, args);
    } else {
      // Queue it for later
      timeoutId = setTimeout(() => {
        lastCallTime = Date.now();
        func.apply(this, args);
      }, delay - timeSinceLastCall);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

This gives users instant feedback on their first keystroke while still protecting the API from spam.

Advanced Pattern #2: Adaptive Debouncing

Here's where it gets interesting. Why use the same delay for everyone?

Fast internet? Short delay. Slow connection? Longer delay to batch requests.

class AdaptiveDebouncer {
  constructor(func, baseDelay = 300) {
    this.func = func;
    this.baseDelay = baseDelay;
    this.timeoutId = null;
    this.requestTimes = [];
    this.maxSamples = 5;
  }

  calculateDelay() {
    if (this.requestTimes.length < 2) return this.baseDelay;

    // Calculate average response time
    const avgResponseTime = this.requestTimes.reduce((a, b) => a + b, 0) 
      / this.requestTimes.length;

    // Adapt delay based on network performance
    // Slower responses = longer debounce to reduce frustration
    return Math.min(this.baseDelay + (avgResponseTime * 0.5), 1000);
  }

  trackRequestTime(duration) {
    this.requestTimes.push(duration);
    if (this.requestTimes.length > this.maxSamples) {
      this.requestTimes.shift();
    }
  }

  debounce(...args) {
    clearTimeout(this.timeoutId);
    const delay = this.calculateDelay();

    this.timeoutId = setTimeout(async () => {
      const startTime = performance.now();
      await this.func.apply(this, args);
      const duration = performance.now() - startTime;
      this.trackRequestTime(duration);
    }, delay);
  }
}
Enter fullscreen mode Exit fullscreen mode

Our users on spotty warehouse WiFi suddenly had a much better experience.

Advanced Pattern #3: Cancellation Tokens

The real production gotcha: what happens when requests return out of order?

User types "NYC" → request fires
User changes to "LA" → new request fires
First request (NYC) returns last and overwrites LA results

💥 Confusing UI, angry users.

class CancellableDebounce {
  constructor(func, delay) {
    this.func = func;
    this.delay = delay;
    this.timeoutId = null;
    this.currentToken = null;
  }

  async execute(...args) {
    clearTimeout(this.timeoutId);

    // Cancel any in-flight request
    if (this.currentToken) {
      this.currentToken.cancelled = true;
    }

    return new Promise((resolve) => {
      this.timeoutId = setTimeout(async () => {
        const token = { cancelled: false };
        this.currentToken = token;

        const result = await this.func.apply(this, args);

        // Only resolve if this request wasn't cancelled
        if (!token.cancelled) {
          resolve(result);
        }
      }, this.delay);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now stale requests get ignored. Search results always match the latest query.

Advanced Pattern #4: Smart Queueing

Sometimes you don't want to debounce everything. What if the user types slowly, pauses, then adds one more character?

function smartDebounce(func, shortDelay = 150, longDelay = 500) {
  let timeoutId;
  let lastKeystrokeTime = Date.now();

  return function(...args) {
    const now = Date.now();
    const timeSinceLastKeystroke = now - lastKeystrokeTime;
    lastKeystrokeTime = now;

    clearTimeout(timeoutId);

    // If user is typing rapidly, use longer delay
    // If they're thinking/pausing, use shorter delay
    const delay = timeSinceLastKeystroke < 100 ? longDelay : shortDelay;

    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}
Enter fullscreen mode Exit fullscreen mode

This respects user intent. Rapid typing = they're still thinking. Slow typing = fire sooner.

The Implementation That Shipped

For our logistics app, we combined techniques:

class ProductionSearchDebouncer {
  constructor(searchFunc) {
    this.searchFunc = searchFunc;
    this.adaptor = new AdaptiveDebouncer(searchFunc);
    this.canceller = new CancellableDebounce(
      (...args) => this.adaptor.debounce(...args),
      300
    );
  }

  async search(query) {
    // Don't search for empty or very short queries
    if (query.length < 2) {
      return { results: [] };
    }

    // Use cancellable debounce for clean request handling
    return await this.canceller.execute(query);
  }
}
Enter fullscreen mode Exit fullscreen mode

Results

After deploying these patterns:

  • 40% reduction in API calls
  • Search felt "snappier" (subjective, but consistent feedback)
  • Zero race condition bugs in production
  • Support tickets about search dropped by 60%

Key Takeaways

  1. Basic debouncing is rarely enough for production apps
  2. Adapt to user conditions - not all networks are created equal
  3. Handle cancellation - async operations will betray you
  4. Respect user intent - sometimes longer delays feel faster
  5. Measure everything - instrument your debouncing to optimize

The difference between a junior and senior approach to debouncing isn't the complexity—it's understanding when and why each pattern matters.

Next time you reach for that simple 300ms debounce, ask yourself: is this actually solving my users' problems, or just the obvious one?


What advanced debouncing patterns have you used in production? Drop them in the comments—I'm always looking to learn more.

Top comments (0)