public final class Debouncer implements AutoCloseable {
private final ScheduledExecutorService scheduler;
private final long delayMillis;
private final AtomicReference<ScheduledFuture<?>> pending = new AtomicReference<>();
public Debouncer(long delay, TimeUnit unit) {
this.delayMillis = unit.toMillis(delay);
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread t = new Thread(runnable, "debouncer");
t.setDaemon(true);
return t;
});
}
public void call(Runnable action) {
ScheduledFuture<?>[] holder = new ScheduledFuture<?>[1];
ScheduledFuture<?> future = scheduler.schedule(() -> {
// Only clear the reference if this task is still the active one.
pending.compareAndSet(holder[0], null);
action.run();
}, delayMillis, TimeUnit.MILLISECONDS);
holder[0] = future;
ScheduledFuture<?> previous = pending.getAndSet(future);
if (previous != null) {
previous.cancel(false);
}
}
public void cancel() {
ScheduledFuture<?> previous = pending.getAndSet(null);
if (previous != null) {
previous.cancel(false);
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
public final class SearchController implements AutoCloseable {
private final Debouncer debouncer = new Debouncer(250, TimeUnit.MILLISECONDS);
private final SearchService searchService;
private final Consumer<List<Result>> onResults;
public SearchController(SearchService searchService, Consumer<List<Result>> onResults) {
this.searchService = searchService;
this.onResults = onResults;
}
public void onQueryChanged(String rawQuery) {
String query = rawQuery == null ? "" : rawQuery.trim();
if (query.isEmpty()) {
debouncer.cancel();
onResults.accept(Collections.emptyList());
return;
}
debouncer.call(() -> runSearch(query));
}
private void runSearch(String query) {
try {
List<Result> results = searchService.query(query);
onResults.accept(results);
} catch (RuntimeException ex) {
onResults.accept(Collections.emptyList());
}
}
@Override
public void close() {
debouncer.close();
}
}
class DebouncerTest {
@Test
void onlyLastCallExecutesWithinWindow() throws Exception {
try (Debouncer debouncer = new Debouncer(100, TimeUnit.MILLISECONDS)) {
AtomicInteger executions = new AtomicInteger();
AtomicReference<String> lastValue = new AtomicReference<>();
for (String value : List.of("a", "ab", "abc")) {
debouncer.call(() -> {
executions.incrementAndGet();
lastValue.set(value);
});
Thread.sleep(20);
}
Thread.sleep(200);
assertEquals(1, executions.get());
assertEquals("abc", lastValue.get());
}
}
@Test
void cancelPreventsPendingExecution() throws Exception {
try (Debouncer debouncer = new Debouncer(100, TimeUnit.MILLISECONDS)) {
AtomicInteger executions = new AtomicInteger();
debouncer.call(executions::incrementAndGet);
debouncer.cancel();
Thread.sleep(200);
assertEquals(0, executions.get());
}
}
}
Debouncing collapses a burst of rapid calls into a single delayed execution: each new call resets a timer, and the action only fires once the caller has been quiet for a fixed window. It is the standard technique behind search-as-you-type, autosave, and any handler that would otherwise thrash a downstream system on every keystroke or event. This snippet implements a reusable debouncer in plain Java on top of a ScheduledExecutorService.
In Debouncer, the core state is a single AtomicReference<ScheduledFuture<?>> holding the currently pending task. Every call(Runnable) schedules the work delay in the future, then atomically swaps that future into the reference via getAndSet. Because getAndSet returns the previous pending future, the code can immediately cancel(false) it — the earlier task never runs, and only the most recent one survives. Using cancel(false) rather than cancel(true) matters: it prevents interrupting a task that has already begun executing, so an in-flight action is allowed to finish cleanly.
The AtomicReference is what makes this safe under concurrency. If several threads call call at once, each swap is atomic, so exactly one future ends up installed and every superseded future is handed back to be cancelled. There is a narrow benign race where a task fires just as a newer one is scheduled; the callback in Debouncer guards against acting on stale state by comparing identities before clearing the reference, avoiding wiping out a newer pending task.
The scheduler is a single-threaded pool built with a daemon ThreadFactory so it never blocks JVM shutdown, and shutdownNow is exposed through close for lifecycle cleanup. SearchController shows the real use case: onQueryChanged is invoked on every keystroke but the expensive runSearch only executes after the user pauses, drastically cutting redundant queries. The trade-off is added latency equal to the debounce window and the fact that intermediate values are dropped by design. When the final value must always run regardless of timing, a debouncer like this should be paired with an explicit flush, whereas throttling is the better fit when steady periodic execution is required instead.
Related snips
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
Share this code
Here's the card — post it anywhere.