Event Handler Patterns
Real applications need event handlers that stay responsive, clean up after themselves, and are easy to debug.
This lesson covers practical patterns you will use often.
Async Event Handlers
Event handlers can be async.
const button = document.querySelector("#load-profile");
const output = document.querySelector("#profile");
button.addEventListener("click", async () => {
const response = await fetch("/api/profile");
const profile = await response.json();
output.textContent = profile.name;
});This is common when an event starts a network request.
Handle Async Errors
Errors inside async handlers do not get caught by surrounding synchronous code.
Use try / catch inside the handler.
button.addEventListener("click", async () => {
try {
button.disabled = true;
const response = await fetch("/api/profile");
if (!response.ok) {
throw new Error("Profile request failed");
}
const profile = await response.json();
output.textContent = profile.name;
} catch (error) {
output.textContent = "Could not load profile.";
console.error(error);
} finally {
button.disabled = false;
}
});Disable buttons while work is in progress if repeated clicks would cause duplicate requests or inconsistent state.
Debouncing
Debouncing delays work until events stop happening for a short time.
It is useful for search boxes and resize handlers.
function debounce(callback, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
callback(...args);
}, delay);
};
}Example:
const search = document.querySelector("#search");
const runSearch = debounce((event) => {
console.log("Search for:", event.target.value);
}, 300);
search.addEventListener("input", runSearch);If the user types quickly, the search waits until they pause.
Throttling
Throttling limits work to at most once during a time window.
It is useful for frequent events like scroll, resize, and mousemove.
function throttle(callback, delay) {
let waiting = false;
return (...args) => {
if (waiting) {
return;
}
callback(...args);
waiting = true;
setTimeout(() => {
waiting = false;
}, delay);
};
}Example:
const reportScroll = throttle(() => {
console.log(window.scrollY);
}, 200);
window.addEventListener("scroll", reportScroll, { passive: true });Debounce vs Throttle
| Pattern | Best for |
|---|---|
| Debounce | wait until activity pauses |
| Throttle | run at a controlled rate during ongoing activity |
Search suggestions often use debounce.
Scroll progress indicators often use throttle.
Cleaning Up Listeners
Listeners can keep references alive.
If a listener points to a removed element or large object, memory may not be released as expected.
function openModal() {
const modal = document.querySelector("#modal");
function handleKeydown(event) {
if (event.key === "Escape") {
closeModal();
}
}
document.addEventListener("keydown", handleKeydown);
function closeModal() {
document.removeEventListener("keydown", handleKeydown);
modal.remove();
}
}Cleanup is especially important for:
- modals
- tooltips
- pages in single-page applications
- listeners on
windowordocument - timers started inside event handlers
Cleanup with AbortController
An AbortController can clean up multiple listeners at once.
const controller = new AbortController();
document.addEventListener("keydown", handleKeydown, {
signal: controller.signal
});
window.addEventListener("resize", handleResize, {
signal: controller.signal
});
controller.abort();When abort() is called, both listeners are removed.
Avoid Duplicate Listeners
A common bug is registering the same behavior repeatedly.
function render() {
button.addEventListener("click", handleClick);
}If render() runs many times, the handler may be added many times.
Move listener setup to a place that runs once, or clean up before registering again.
Debugging Event Problems
Useful debugging steps:
- log
event.type - log
event.target - log
event.currentTarget - inspect
event.composedPath() - check whether
preventDefault()was called - check whether propagation was stopped
- check the browser DevTools event listener panel
document.addEventListener("click", (event) => {
console.log({
type: event.type,
target: event.target,
currentTarget: event.currentTarget,
path: event.composedPath()
});
});Remove broad logging after debugging. Global event logs can become noisy quickly.
Summary
Production event code needs more than basic listeners.
Use async handlers carefully, catch errors, debounce or throttle frequent events,
clean up long-lived listeners, and debug with target, currentTarget, and the
event path.