debounce

← All operations

debounce(options: { waitMs: number; key?; maxWaitMs? }): RouteBuilder<Current>

Suppress bursts of exchanges, releasing only the last one in a burst after a quiet period. Useful when only the final state matters: file-system change batching, search-as-you-type, or collapsing a flurry of webhook retries.

.id('file-watcher')
.from(file({ path: './config', watch: true }))
.debounce({ waitMs: 500 }) // wait for editing to finish
.process(reloadConfig)
.to(log())

Each arrival is held (not passed downstream) and resets a waitMs quiet timer; a newer arrival supersedes and drops the one being held. When the timer finally fires, the held exchange is released through the steps that follow .debounce().

Options

  • waitMs (required) -- the quiet window in milliseconds. An exchange is released only after waitMs elapses with no newer arrival in its group.
  • key -- a selector that debounces independently per group, e.g. one window per file path: key: (ex) => ex.body.filePath. When omitted, the whole route shares a single window.
  • maxWaitMs -- an upper bound on how long an exchange may be held, measured from the START of its burst and never reset. It guarantees eventual release under continuous activity (otherwise a steady stream of arrivals could reset waitMs forever and starve the trailing edge). Must be >= waitMs.
// Per-path debounce with a 5s ceiling on continuous edits.
.debounce({ waitMs: 500, key: (ex) => ex.body.filePath, maxWaitMs: 5000 })

Semantics

  • Trailing edge. Only the last exchange in a burst is released; earlier ones are dropped. State is per-route (and per key group).
  • Held outside the queue. Debounce is the one operation that breaks the "process each exchange immediately" model: it holds an exchange outside the pipeline queue and re-runs it later. A released exchange runs the steps after .debounce() as a fresh exchange (new id, preserved correlation id) with its own exchange:started / :completed lifecycle. Because the released exchange is the route's primary flow, the detached run honors the route-scope .error() handler and enforces the route's .output() schemas before completing.
  • Balanced lifecycle. Every arrival ends in route:exchange:dropped with reason "debounced": superseded arrivals when a newer one replaces them, and the absorbed trailing arrival at release time (its content continues as the released clone). No arrival id is ever left without a terminal event.
  • Flush on drain / shutdown. A pending exchange is released promptly when the route drains or shuts down (release reason "flush"), rather than being lost or waiting out its timer.
  • Route scope only, not wrappable. Debounce is a route-scope operation: it is deliberately not available inside a fan-out path (a held exchange has no meaning inside a transient path clone), and step-scope wrappers (.retry() / .error() / .timeout() / ...) refuse to wrap it at build time. Its execute never fails per-exchange, so a wrapper could never trigger, and it would falsely suggest coverage of the released exchange's downstream failures. Wrap the steps downstream of .debounce() instead.

Events

  • route:operation:debounce:held -- { routeId, exchangeId, correlationId, key? }, fired when an arrival is held and the quiet timer is armed or reset.
  • route:operation:debounce:dropped -- { routeId, exchangeId, correlationId, key? }, fired when a held exchange is superseded by a newer arrival in the same burst.
  • route:operation:debounce:released -- { routeId, exchangeId, correlationId, key?, reason }, fired when the trailing exchange is released. reason is "quiet" (the window closed), "maxWait" (the cap fired during continuous activity), or "flush" (a drain / shutdown released it early).