Skip to content

Aplicar debounce a una llamada a función snippet

El debounce espera silencio: el callback se dispara solo tras N milisegundos de calma, así que una tormenta de pulsaciones cuesta una llamada en lugar de una por tecla.

El debounce espera silencio: el callback se dispara solo tras N milisegundos de calma, así que una tormenta de pulsaciones cuesta una llamada en lugar de una por tecla. La trampa es el estado — el id del timer debe sobrevivir entre llamadas (closure o campo), cada llamada debe limpiar el timer anterior, y leading vs trailing edge decide si la PRIMERA llamada se dispara de inmediato o todo espera al silencio. Todo lenguaje con timers escribe la misma forma; solo cambia la llamada de limpieza.

Receta ejecutable · 12 lenguajes
Frontend & DOMdebouncetimerseventsasyncthrottle

Every language

12 lenguajes, 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.