Skip to content

Débouncer un appel de fonction snippet

Le debounce attend le calme : le callback ne se déclenche qu'après N millisecondes de silence, donc une tempête de frappes coûte un appel au lieu d'un par touche.

Le debounce attend le calme : le callback ne se déclenche qu'après N millisecondes de silence, donc une tempête de frappes coûte un appel au lieu d'un par touche. Le piège est l'état — l'identifiant du minuteur doit survivre d'un appel à l'autre (closure ou champ), chaque appel doit annuler le minuteur précédent, et le choix entre front montant (leading) et front descendant (trailing) décide si le PREMIER appel part immédiatement ou si tout attend le silence. Tout langage disposant de minuteurs épelle la même forme ; seul l'appel d'annulation diffère.

Recette exécutable · 12 langages
Frontend & DOMdebouncetimerseventsasyncthrottle

Every language

12 langages, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
function debounce(fn, ms) {
  let timer;                        // the state that must survive calls
  function debounced(...args) {
    clearTimeout(timer);            // drop the pending fire
    timer = setTimeout(() => fn(...args), ms); // rearm from NOW
  }
  debounced.cancel = () => clearTimeout(timer);
  return debounced;
}

const save = debounce(() => api.save(draft), 300);
input.addEventListener('input', save);
// teardown (unmount): save.cancel();

The timer id held in the closure IS the debounce — recreate the wrapper per event and every call fires. cancel() is the unmount companion; flush() (fire immediately, pending or not) and a leading:true variant (fire on the FIRST call, then stay quiet until silence) are the standard extensions.

TSTypeScript
function debounce<F extends (...args: any[]) => void>(
  fn: F,
  ms: number,
) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const debounced = (...args: Parameters<F>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
  debounced.cancel = () => clearTimeout(timer);
  return debounced;
}

Parameters<F> carries the argument types through so call sites stay checked — spelling args unknown[] would NOT be assignable to fn(...). ReturnType<typeof setTimeout> is the honest timer type: the DOM numbers timers, Node hands back a Timeout object.

GoGo
import (
	"sync"
	"time"
)

type Debouncer struct {
	mu    sync.Mutex
	timer *time.Timer // the surviving state
}

// each call drops the pending fire and rearms it at now+ms:
func (d *Debouncer) call(fn func(), ms time.Duration) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.timer != nil {
		d.timer.Stop()
	}
	d.timer = time.AfterFunc(ms, fn)
}

func (d *Debouncer) cancel() {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.timer != nil {
		d.timer.Stop()
		d.timer = nil
	}
}

The Stop-then-AfterFunc swap is the whole pattern: each call cancels the pending fire and rearms a new one, under a mutex because timers are not goroutine-safe. In a pipeline the leaner shape is a goroutine owning one time.Timer and calling t.Reset on each send — no mutex, one timer. Either way fn runs on a timer goroutine, not the caller's.

RsRust
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

/// send(()) on the returned handle restarts the quiet window;
/// f fires only after `ms` passes with no pings.
fn debouncer<F: Fn() + Send + 'static>(ms: u64, f: F) -> mpsc::Sender<()> {
    let (tx, rx) = mpsc::channel::<()>();
    thread::spawn(move || {
        while rx.recv().is_ok() {
            let mut deadline = Instant::now() + Duration::from_millis(ms);
            loop {
                let remain = deadline.saturating_duration_since(Instant::now());
                if remain.is_zero() {
                    break;
                }
                match rx.recv_timeout(remain) {
                    Ok(()) => deadline = Instant::now() + Duration::from_millis(ms),
                    Err(mpsc::RecvTimeoutError::Timeout) => break,
                    Err(mpsc::RecvTimeoutError::Disconnected) => return,
                }
            }
            f(); // quiet for the full window
        }
    });
    tx
}

Rust has no stdlib timer, so debounce is runtime-shaped, not stdlib-shaped: this version parks a thread on recv_timeout and pushes the deadline out on every ping. Real code reaches for tokio::time::sleep inside a select! loop that restarts on each ping — the same state machine with an async runtime instead of a dedicated thread.

PHPPHP
// No timers in plain CLI PHP — and a while(true) { sleep(...); } poll is
// NOT debounce: it blocks its own worker and fires on its own schedule,
// not on quiet. The honest form is an event loop (ReactPHP):

$loop = React\EventLoop\Loop::get();

function makeDebounced($loop, callable $fn, float $ms): callable {
    $timer = null;
    return function () use (&$timer, $loop, $fn, $ms) {
        if ($timer !== null) {
            $loop->cancelTimer($timer);   // clearTimeout
        }
        $timer = $loop->addTimer($ms / 1000, $fn); // setTimeout, rearm
    };
}

$save = makeDebounced($loop, fn() => print("saved\n"), 300);
$save();
$save(); // restarts the 300ms window — one fire when it goes quiet
$loop->run();

The language gap is the trap: without an event loop there is no timer to clear, and the sleep-loop poll people reach for is the wrong shape entirely. Inside ReactPHP this is clearTimeout/setTimeout verbatim — and the timer handle must live in a use-by-reference closure, PHP's only surviving-state slot here.

PyPython
import threading

def debounce(fn, ms):
    timer = None
    def debounced(*args):
        nonlocal timer            # the surviving state
        if timer is not None:
            timer.cancel()        # drop the pending fire
        timer = threading.Timer(ms / 1000, fn, args)
        timer.start()
    def cancel():
        nonlocal timer
        if timer is not None:
            timer.cancel()
            timer = None
    debounced.cancel = cancel
    return debounced

Timer.cancel only flags the run to be skipped — if the window already elapsed and the thread is inside fn, cancel returns False and that fire happens anyway. Each call spawns a thread: fine at UI rates (a few per second), wrong at high rates — past that, feed one worker thread via a queue instead.

C#C#
using System;
using System.Threading;
using System.Threading.Tasks;

sealed class Debouncer : IDisposable
{
    private CancellationTokenSource? cts;

    public void Debounce(TimeSpan wait, Action fire)
    {
        // swap in a fresh CTS, then cancel the old one — race-free:
        var old = Interlocked.Exchange(ref cts, new CancellationTokenSource());
        old?.Cancel();
        old?.Dispose();
        var token = cts.Token;
        _ = Task.Run(async () =>
        {
            try { await Task.Delay(wait, token); }
            catch (TaskCanceledException) { return; } // superseded
            fire();
        });
    }

    public void Dispose() { cts?.Cancel(); cts?.Dispose(); }
}

The CancellationTokenSource swap is the race-free idiom: Interlocked.Exchange arms the new token and returns the old one to cancel, so a concurrent Debounce call cannot leave two live waits. The alternative — one System.Threading.Timer reset via Change(dueTime, InfiniteTimeSpan) — is cheaper but hands you a race on the reset itself unless the same lock guards every call.

JvJava
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;

class Debouncer implements AutoCloseable {
    private final ScheduledExecutorService exec =
            Executors.newSingleThreadScheduledExecutor();
    private final AtomicReference<ScheduledFuture<?>> pending = new AtomicReference<>();

    void call(Runnable fn, long ms) {
        // arm the replacement FIRST, then drop the old fire:
        ScheduledFuture<?> old = pending.getAndSet(
                exec.schedule(fn, ms, TimeUnit.MILLISECONDS));
        if (old != null) old.cancel(false);
    }

    @Override public void close() {
        ScheduledFuture<?> f = pending.getAndSet(null);
        if (f != null) f.cancel(false);
        exec.shutdownNow();
    }
}

The AtomicReference to the ScheduledFuture is the swap: getAndSet arms the replacement and hands you the old handle in one atomic step, so two racing calls cannot both end up cancelled. cancel(false) never interrupts a fire already running; without shutdownNow() the executor thread keeps the JVM alive.

SwSwift
@MainActor
final class Debouncer {
    private var task: Task<Void, Never>?
    private let wait: Duration

    init(milliseconds: Int) { wait = .milliseconds(milliseconds) }

    func call(_ fire: @escaping @MainActor () -> Void) {
        task?.cancel()             // drop the pending fire
        task = Task { [wait] in
            do {
                try await Task.sleep(for: wait)
            } catch {
                return             // cancelled while sleeping — superseded
            }
            fire()
        }
    }

    deinit { task?.cancel() }
}

Store the Task, cancel it, relaunch — but the swallow is the trap: try? Task.sleep eats the CancellationError and fire() still runs, so catch the throw and return instead. @MainActor on the class keeps both cancel and fire off data races with UI state; deinit cancels so a deallocated owner fires nothing.

KtKotlin
import kotlinx.coroutines.*

class Debouncer(private val scope: CoroutineScope) {
    private var job: Job? = null // this one field IS the debounce

    fun call(ms: Long, fn: suspend () -> Unit) {
        job?.cancel()            // drop the pending fire
        job = scope.launch {
            delay(ms)            // quiet window — cancel kills mid-sleep
            fn()
        }
    }
}

// runBlocking {
//     val d = Debouncer(this)
//     repeat(5) { d.call(100) { println("saved") } } // one fire
// }

Structured cancellation makes this trivial next to Java's AtomicReference dance: cancelling the old Job aborts its delay() in place, so fn() is unreachable for superseded calls. The scope must outlive the quiet window — cancel the scope and every pending fire dies with it (that is the feature, not a leak).

RbRuby
def debounce(fn, ms)
  generation = 0                 # epoch counter — the surviving state
  lambda do |*args|
    generation += 1
    mine = generation            # capture MY epoch
    Thread.new do
      sleep(ms / 1000.0)
      fn.call(*args) if mine == generation # only the latest call runs
    end
  end
end

The generation (epoch) pattern replaces timer cancellation: each call increments the counter and the thread fires only if its captured epoch is still the latest — anything older wakes up dead. It costs one short-lived thread per call (UI rates only); concurrent-ruby's Concurrent::ScheduledTask gives the cancel-handle form instead.

ZigZig
const std = @import("std");
const Thread = std.Thread;

/// One worker thread + condvar: each ping resets the deadline; fire()
/// runs only after quiet_ms passes with no pings.
const Debouncer = struct {
    mutex: Thread.Mutex = .{},
    cond: Thread.Condition = .{},
    deadline: i64 = 0, // ms timestamp; 0 = idle
    quiet_ms: i64,
    fire: *const fn () void,

    fn ping(d: *Debouncer) void {
        d.mutex.lock();
        defer d.mutex.unlock();
        d.deadline = std.time.milliTimestamp() + d.quiet_ms; // reset
        d.cond.signal(); // wake the waiter
    }

    fn run(d: *Debouncer) void {
        d.mutex.lock();
        defer d.mutex.unlock();
        while (true) {
            if (d.deadline == 0) {
                d.cond.wait(&d.mutex); // idle — sleep until a ping
                continue;
            }
            const now = std.time.milliTimestamp();
            if (now >= d.deadline) {
                d.deadline = 0;
                d.mutex.unlock();
                d.fire(); // run WITHOUT the lock or ping deadlocks
                d.mutex.lock();
            } else {
                d.cond.timedWait(
                    &d.mutex,
                    @intCast((d.deadline - now) * std.time.ns_per_ms),
                ) catch {}; // Timeout → loop and re-check the clock
            }
        }
    }
};

No stdlib timer event loop, and std.time.sleep BLOCKS — the honest shape is one worker thread on a condition variable whose timedWait deadline every ping pushes out. The deadline field reset under the mutex IS the debounce state; the trap is firing while holding the lock, which deadlocks the next ping.