debounce function
Java programming
performance optimization
event handling
coding best practices

implementing debounce in Java

Data Structures & Algorithms practice on Codemia

Step through 300 algorithm problems with animated visualisers that show the data structure changing as the code runs.

Practice algorithms

Introduction

Debouncing ensures a function executes only after a specified quiet period — if the function is triggered again before the delay expires, the timer resets. In Java, debounce is implemented using ScheduledExecutorService or Timer to delay execution, cancelling the previous scheduled task each time a new event arrives. This pattern is essential for handling rapid-fire events like search-as-you-type, window resize handlers, or sensor data processing where you want to act only on the final event in a burst.

Basic Debouncer with ScheduledExecutorService

java
1import java.util.concurrent.*;
2
3public class Debouncer {
4    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
5    private ScheduledFuture<?> pendingTask;
6    private final long delayMs;
7
8    public Debouncer(long delayMs) {
9        this.delayMs = delayMs;
10    }
11
12    public synchronized void call(Runnable action) {
13        // Cancel the previous pending task
14        if (pendingTask != null && !pendingTask.isDone()) {
15            pendingTask.cancel(false);
16        }
17        // Schedule a new task after the delay
18        pendingTask = scheduler.schedule(action, delayMs, TimeUnit.MILLISECONDS);
19    }
20
21    public void shutdown() {
22        scheduler.shutdown();
23    }
24}
25
26// Usage
27Debouncer debouncer = new Debouncer(300);
28
29// Rapid calls — only the last one executes
30debouncer.call(() -> System.out.println("Search: a"));
31debouncer.call(() -> System.out.println("Search: ab"));
32debouncer.call(() -> System.out.println("Search: abc"));
33// After 300ms, prints: "Search: abc" (only the last call)

Each call cancels the previous scheduled task and reschedules. Only the final call's action runs after the delay expires.

Generic Debouncer with Key Support

java
1import java.util.concurrent.*;
2
3public class KeyedDebouncer<K> {
4    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
5    private final ConcurrentHashMap<K, ScheduledFuture<?>> pending = new ConcurrentHashMap<>();
6    private final long delayMs;
7
8    public KeyedDebouncer(long delayMs) {
9        this.delayMs = delayMs;
10    }
11
12    public void debounce(K key, Runnable action) {
13        ScheduledFuture<?> existing = pending.get(key);
14        if (existing != null) {
15            existing.cancel(false);
16        }
17        ScheduledFuture<?> future = scheduler.schedule(() -> {
18            action.run();
19            pending.remove(key);
20        }, delayMs, TimeUnit.MILLISECONDS);
21        pending.put(key, future);
22    }
23
24    public void shutdown() {
25        scheduler.shutdown();
26    }
27}
28
29// Usage: debounce different keys independently
30KeyedDebouncer<String> debouncer = new KeyedDebouncer<>(500);
31
32debouncer.debounce("user-123", () -> saveUserPreferences("user-123"));
33debouncer.debounce("user-456", () -> saveUserPreferences("user-456"));
34// Each user's save is debounced independently

Keyed debouncing allows independent debounce timers per key, useful for per-user or per-resource rate limiting.

Debouncer with Timer (Simpler Alternative)

java
1import java.util.Timer;
2import java.util.TimerTask;
3
4public class SimpleDebouncer {
5    private Timer timer;
6    private final long delayMs;
7
8    public SimpleDebouncer(long delayMs) {
9        this.delayMs = delayMs;
10    }
11
12    public void call(Runnable action) {
13        if (timer != null) {
14            timer.cancel();
15        }
16        timer = new Timer();
17        timer.schedule(new TimerTask() {
18            @Override
19            public void run() {
20                action.run();
21            }
22        }, delayMs);
23    }
24}

Timer is simpler but less flexible than ScheduledExecutorService. It creates a new thread per timer instance, which is wasteful for many concurrent debouncers.

Debounce vs Throttle

java
1// THROTTLE: execute at most once per interval
2public class Throttler {
3    private final long intervalMs;
4    private long lastExecution = 0;
5
6    public Throttler(long intervalMs) {
7        this.intervalMs = intervalMs;
8    }
9
10    public synchronized void call(Runnable action) {
11        long now = System.currentTimeMillis();
12        if (now - lastExecution >= intervalMs) {
13            lastExecution = now;
14            action.run();
15        }
16    }
17}
18
19// DEBOUNCE: execute after quiet period
20// - Typing "hello" with 100ms between keystrokes and 300ms debounce:
21//   Debounce: fires once after "o" + 300ms
22//   Throttle: fires on "h", then "l" (every 300ms)
FeatureDebounceThrottle
When it firesAfter last event + delayAt fixed intervals
Events in a burstOnly the last one runsFirst (or periodic) ones run
Use caseSearch input, form validationScroll handlers, API rate limiting

Android/Kotlin Debounce with Coroutines

kotlin
1import kotlinx.coroutines.*
2
3class CoroutineDebouncer(
4    private val delayMs: Long,
5    private val scope: CoroutineScope
6) {
7    private var job: Job? = null
8
9    fun debounce(action: suspend () -> Unit) {
10        job?.cancel()
11        job = scope.launch {
12            delay(delayMs)
13            action()
14        }
15    }
16}
17
18// Usage in an Android ViewModel
19class SearchViewModel : ViewModel() {
20    private val debouncer = CoroutineDebouncer(300, viewModelScope)
21
22    fun onSearchTextChanged(query: String) {
23        debouncer.debounce {
24            val results = repository.search(query)
25            _searchResults.value = results
26        }
27    }
28}

Thread-Safe Debouncer with CompletableFuture

java
1import java.util.concurrent.*;
2import java.util.function.Supplier;
3
4public class AsyncDebouncer<T> {
5    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
6    private ScheduledFuture<?> pendingTask;
7    private CompletableFuture<T> pendingResult;
8    private final long delayMs;
9
10    public AsyncDebouncer(long delayMs) {
11        this.delayMs = delayMs;
12    }
13
14    public synchronized CompletableFuture<T> call(Supplier<T> action) {
15        if (pendingTask != null) {
16            pendingTask.cancel(false);
17            pendingResult.cancel(false);
18        }
19
20        pendingResult = new CompletableFuture<>();
21        CompletableFuture<T> result = pendingResult;
22
23        pendingTask = scheduler.schedule(() -> {
24            try {
25                result.complete(action.get());
26            } catch (Exception e) {
27                result.completeExceptionally(e);
28            }
29        }, delayMs, TimeUnit.MILLISECONDS);
30
31        return result;
32    }
33}

Common Pitfalls

  • Not cancelling the previous task before scheduling a new one: Without pendingTask.cancel(), every call schedules an additional task. Instead of running once after the delay, the action runs once per call — defeating the purpose of debouncing.
  • Using cancel(true) which interrupts the running task: cancel(true) sends an interrupt to a running task, which can cause InterruptedException in the action. Use cancel(false) to cancel only pending (not yet running) tasks, letting in-progress work complete.
  • Forgetting to shut down the ScheduledExecutorService: The executor thread keeps the JVM alive even after the main method exits. Always call scheduler.shutdown() in a cleanup method, or use daemon threads: Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r); t.setDaemon(true); return t; }).
  • Not synchronizing access in multi-threaded environments: If call() is invoked from multiple threads, the check-and-cancel-and-schedule sequence must be atomic. Use synchronized or a lock to prevent race conditions where two tasks are scheduled simultaneously.
  • Confusing debounce with throttle: Debounce waits for a quiet period after the last event. Throttle limits execution to a fixed rate. Using throttle when debounce is needed (e.g., search-as-you-type) causes premature execution with incomplete input.

Summary

  • Debounce delays execution until a quiet period elapses after the last call
  • Use ScheduledExecutorService with cancel() and schedule() for the standard Java implementation
  • Use keyed debouncers when events need independent timers per key (per-user, per-resource)
  • Throttle fires at fixed intervals; debounce fires after the last event — choose based on your use case
  • Always cancel pending tasks before scheduling new ones and shut down the executor on cleanup
  • In Kotlin/Android, use coroutines with Job.cancel() and delay() for cleaner debounce code

Related reading
Course
Intermediate
27 lessons
15 hours
DSA Fundamentals

Master algorithmic patterns and data structures through hands-on LeetCode-style problems - from arrays and hashing to dynamic programming and advanced graphs.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Data Structures & Algorithms practice on Codemia

Step through 300 algorithm problems with animated visualisers that show the data structure changing as the code runs.

Practice algorithms

All Rights Reserved.