Lodash Debounce and Defer Execution Timeline

Combining Lodash's _.debounce and _.defer coordinates JavaScript's asynchronous event loop with timer-based threshold delays. This article examines the exact step-by-step execution timeline when merging these two utilities, detailing the behavior of the call stack, the macro-task queue, and the internal timers according to which function wraps the other.


Core Mechanics of Each Utility

To understand the combined timeline, consider their individual mechanics:


Scenario A: Deferring a Debounced Function (_.defer(_.debounce(fn, wait)))

In this scenario, a debounced instance is scheduled using _.defer:

const debouncedFn = _.debounce(targetFn, 100);
_.defer(debouncedFn);

Step-by-Step Execution Timeline:

  1. Tick 0 (Synchronous Execution - Call Stack):

    • _.debounce(targetFn, 100) initializes and returns the debounced wrapper.
    • _.defer(debouncedFn) runs. It registers debouncedFn to the browser or Node.js macro-task timer queue with a minimum delay (typically 0–1ms).
    • Synchronous execution continues until the call stack is completely empty.
  2. Tick 1 (Current Stack Cleared - Macro-Task Resolution):

    • The event loop picks the deferred task from the macro-task queue.
    • debouncedFn is executed.
    • Rather than executing targetFn, the debounced function records the timestamp (lastCallTime = now()) and schedules an internal timer (setTimeout) configured for the wait period (100ms).
    • The call stack clears again.
  3. Intermediary Ticks (Idle / Non-blocking):

    • If no further calls are made to debouncedFn, the 100ms timer runs in the background. Other micro-tasks and macro-tasks continue to process normally.
  4. Tick 2 (Debounce Duration Met - Final Invocation):

    • At \(t \ge \text{Tick 1 timestamp} + 100\text{ms}\), the debounce timer fires.
    • The timer callback pushes the invocation of targetFn to the macro-task queue.
    • The event loop executes targetFn with the trailing arguments.

Total Delay: Call stack clear time + \(\approx 1\text{ms}\) (defer) + \(100\text{ms}\) (wait).


Scenario B: Debouncing a Deferred Function (_.debounce(..., wait) containing _.defer)

In this scenario, the debounced function wraps a call to _.defer:

const debouncedAndDeferred = _.debounce(() => {
  _.defer(targetFn);
}, 100);

debouncedAndDeferred();

Step-by-Step Execution Timeline:

  1. Tick 0 (Synchronous Execution - Call Stack):

    • debouncedAndDeferred() is invoked synchronously.
    • The debounce logic sets an internal timer for 100ms.
    • Synchronous code continues running; the call stack clears.
  2. Tick 1 (Debounce Timer Resolves):

    • After 100ms of inactivity, the debounce timer completes.
    • The wrapper macro-task runs on the call stack.
    • Inside the wrapper, _.defer(targetFn) is called.
    • targetFn is placed into the macro-task queue (scheduled via setTimeout(..., 1)).
    • The wrapper finishes execution, and the call stack clears.
  3. Tick 2 (Deferred Macro-Task Resolves):

    • Any pending micro-tasks resolve first.
    • The event loop pulls the deferred callback containing targetFn from the macro-task queue.
    • targetFn executes.

Total Delay: \(100\text{ms}\) (wait) + Call stack clear time + \(\approx 1\text{ms}\) (defer).


Key Behavioral Differences

Characteristic _.defer(_.debounce(fn, wait)) _.debounce(() => _.defer(fn), wait)
Timer Start Point After current stack clears (Macro-task 1) Immediately upon debounced function call
Function Deferred The scheduling wrapper The actual target execution (fn)
Leading Edge Effects If leading: true, executes right after call stack clears If leading: true, _.defer fires immediately, executing fn on the very next tick