Advanced Event Handling

addEventListener has options most developers never use. once removes itself. passive unlocks scroll performance. Custom events let any part of the app broadcast to any other.

May 1, 20264 min read9 / 12

addEventListener has a third argument that most tutorials skip. It contains options that change when and how the handler fires. Understanding these unlocks better performance and cleaner communication patterns in vanilla JS apps.

The Essentials

  1. once: true: The handler fires exactly once, then removes itself automatically. No manual cleanup needed.
  2. passive: true: Tells the browser your handler will not call preventDefault() in this event. Enables the browser to optimize scroll rendering without waiting for your code to finish.
  3. removeEventListener: Manually removes a specific handler from an element. Used in SPAs when navigating away from a route that registered handlers on elements that persist in the DOM.
  4. Custom events: You can create and dispatch your own events on any DOM element or on window. Any other part of the app can listen for them. This is broadcast-style communication without tight coupling.
  5. Multiple handlers fire synchronously: If ten functions are registered for the same event on the same element, they fire one after another in the order they were registered.

The Third Argument: Options

JavaScript
element.addEventListener('click', handler, { once: true, passive: false });

once

Setting once: true makes the handler a one-shot listener. After it fires the first time, it is automatically removed:

JavaScript
modal.addEventListener('click', closeModal, { once: true }); // closeModal fires the first time the modal is clicked, then is gone

This is useful for setup operations that should happen exactly once: first-time user onboarding, a one-time permission prompt, or any event you only need to handle once per page session.

passive

This one has a subtle relationship with scroll performance.

By default, the browser cannot start scrolling until your scroll or touchmove event handler finishes. This is because you might call event.preventDefault() in that handler, which would cancel the scroll. The browser waits to find out.

On a heavy page, a slow handler in a scroll listener makes the page feel choppy: your finger moves but the content does not follow immediately.

Setting passive: true is a promise to the browser: "I will not call preventDefault() in this handler. You can start scrolling immediately without waiting for my code."

JavaScript
document.addEventListener('scroll', updateProgressBar, { passive: true });

For scroll and touch handlers that are purely observational (tracking scroll position, updating a progress indicator), passive: true is the right choice.

Removing Event Listeners

Handlers registered with addEventListener stay registered until you explicitly remove them or the element is garbage collected. In a single-page application where elements persist across route changes, stale handlers can accumulate.

JavaScript
function handleClick() { /* ... */ } button.addEventListener('click', handleClick); // Later, when navigating away: button.removeEventListener('click', handleClick);

removeEventListener requires the exact same function reference that was used in addEventListener. An inline function declared inside addEventListener cannot be removed because you have no reference to it. For handlers you expect to clean up, store the function in a variable first.

Custom Events

The DOM event system is not limited to built-in events. You can define your own and dispatch them on any element or on window.

JavaScript
// Dispatch a custom event from one part of the app const cartEvent = new CustomEvent('cart:updated', { detail: { itemCount: 3 } }); window.dispatchEvent(cartEvent); // Listen from a completely different part of the app window.addEventListener('cart:updated', (event) => { cartBadge.textContent = event.detail.itemCount; });

CustomEvent takes an event name (any string) and an options object. The detail property is where you attach arbitrary data to the event. Listeners read it from event.detail.

This is a broadcast pattern: the dispatcher does not know who is listening, and the listeners do not know where the event came from. It is structurally similar to context in React or a pub-sub event bus. The difference is that it uses the DOM API directly rather than a framework abstraction.

How Multiple Handlers Fire

JavaScript is single-threaded. When ten handlers are registered for the same event on the same element, they fire one after another in registration order. There is no parallelism. Handler 2 does not start until Handler 1 returns.

This is a useful guarantee: if order matters, the registration order controls execution order. If you need concurrent behavior, you would need to use Web Workers, which have their own separate thread.

Further Reading and Watching

Video: