How do the once and signal options of addEventListener remove DOM listeners for you, and when would you choose signal over calling removeEventListener yourself?
answer
- let the browser do the removing
- one-shot, removed before it runs
- one controller, many targets
- anonymous handlers become removable
- an aborted signal never registers
basics
~20 sonce: true makes the browser remove the listener as it invokes the callback the first time. signal takes an AbortSignal; aborting its controller removes every listener registered with that signal, across any number of targets, without you holding a reference to each callback.
solid answer
~50 sBoth options move teardown from your bookkeeping into the browser. `{ once: true }` registers a one-shot listener — the browser removes it from the listener list before invoking the callback, so it cannot run twice even if the same event is re-dispatched from inside the handler. `{ signal }` ties the listener's lifetime to an `AbortSignal`: one `AbortController` can cover listeners on `window`, `document` and any number of elements, and a single `controller.abort()` removes all of them at once. That is the big win over `removeEventListener`, which needs the exact callback reference and the matching capture flag for every listener individually — so inline arrow functions become safe to use. The pattern I reach for is one controller per component or per page lifecycle, created on setup and aborted on teardown. Both options have been supported in every current browser since 2021.
code
javascript · 14 linesfunction startDrag(target) {
const controller = new AbortController();
const { signal } = controller;
document.addEventListener('pointermove', (e) => {
target.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
}, { signal });
document.addEventListener('pointerup', () => {
controller.abort(); // removes both listeners at once
}, { signal, once: true });
}
startDrag(document.createElement('div'));go deeper
Know that { once: true } makes a listener fire a single time and then unregister itself, and that passing a signal lets you drop listeners later by calling abort() on its controller.
Explain the mechanics: once removes the listener before invoking the callback, a signal covers listeners across many targets at once, and an already-aborted signal registers nothing.
Show how you use these to make teardown structural in a real app — one controller per lifecycle, aborted in the unmount path — and how that removes the class of leak caused by mismatched removeEventListener calls.
Own the convention across a codebase: decide where controllers are created and who owns aborting them, and be able to argue why signal-based teardown is more reviewable than per-listener bookkeeping that a reviewer must verify by eye.
## The bookkeeping problem `removeEventListener` needs the exact function object you registered plus the matching capture flag. That forces you to hold a named reference for every listener you may ever need to detach, and to keep the add site and the remove site in agreement. In a component with six listeners spread across `window`, `document` and a couple of elements, this is pure ceremony — and forgetting one leaves a handler running against a torn-down view, keeping its closure alive. Two options in the `addEventListener` options bag remove the ceremony for the two common cases: "run at most once" and "live exactly as long as this thing does". ## once: the one-shot listener ```js img.addEventListener('load', () => start(), { once: true }); ``` The listener is invoked at most one time, then it is gone. The important detail is the ordering: the browser removes the listener from the target's listener list **before** calling the callback, not after. So if the callback itself causes the same event to be dispatched at that target again, it will not re-enter — a genuine re-entrancy guard, not just a convenience. `once` is right for anything that is by nature a single transition: a first user interaction that unlocks audio, a one-time "the animation finished" hook, a modal's first outside click. It is wrong for anything recurring, and it is a subtle trap when the event can fire in a state you wanted to ignore — the listener is consumed either way, so an early `return` inside the callback still leaves you unregistered. Note that `once` takes no part in removal matching. If you *also* want to detach a one-shot listener before it ever fires, you still need its reference and the right capture flag — or a signal. ## signal: lifetime by abort ```js const controller = new AbortController(); const { signal } = controller; window.addEventListener('resize', onResize, { signal }); document.addEventListener('keydown', onKey, { signal }); el.addEventListener('pointerdown', () => drag(), { signal }); controller.abort(); // all three listeners are removed ``` When the signal is aborted, the browser removes every listener that was registered with it. Several properties make this the strongest teardown primitive the DOM has: - **It is not per-target.** One controller can own listeners on `window`, `document`, the shadow root and a dozen elements. Teardown is one call. - **It does not need the callback reference.** Anonymous arrow functions are fine, which is exactly the case `removeEventListener` cannot serve. - **It composes with the other options.** `{ signal, passive: true, capture: true }` is a normal registration; the signal only controls lifetime. - **It is idempotent.** Calling `abort()` twice is harmless, so a teardown path that runs more than once does not misbehave. One edge worth knowing: if the signal is **already aborted** when `addEventListener` is called, the listener is not added at all — silently, with no error. That is the desired behaviour (registering into a dead lifecycle should do nothing), but it can look like "my listener never fires" if you reuse one controller across two lifecycles. An `AbortController` is single-use: create a fresh one each time you set up. ## Choosing between them They answer different questions and combine happily: - Use `once` when the *event* should only be handled a single time. `{ once: true }` alone is enough; no controller needed. - Use `signal` when the *listener* should live exactly as long as some scope — a component instance, a route, an open dialog, a drag gesture. - Use both when a one-shot listener also has to be cancellable: `{ once: true, signal }` fires at most once and disappears if the scope ends first. A drag interaction is the classic combination: on `pointerdown`, create a controller, register `pointermove` and `pointerup` with its signal, and abort the controller in the `pointerup` handler. Every listener of the gesture disappears together, whichever way the gesture ended. ## What it does not do `signal` governs registration, not dispatch. It cannot pause a listener and resume it later — aborting is terminal, and re-registering means calling `addEventListener` again with a new signal. It also does not help with handlers registered through `on*` properties; those are cleared by assigning `null`. And it is not a substitute for the identity rule when you *do* call `removeEventListener` — there, the callback reference and capture flag still have to match. Support is universal in current browsers: the `signal` option shipped across the evergreen engines in 2021, and `once` long before that.
- What happens if you pass an AbortSignal that has already been aborted to addEventListener?The listener is not added, silently — no error, no callback. That is correct for a dead lifecycle, but it is why an AbortController must be treated as single-use: create a new one per setup rather than reusing one across mounts, or your second round of listeners never registers.
- Is a once listener removed before or after its callback runs, and why does it matter?Before. The browser takes it out of the listener list and then invokes the callback, so if the callback triggers the same event on the same target it cannot re-enter that listener. Removing afterwards would leave a window where a re-entrant dispatch runs it twice.
- Can one AbortController manage listeners on more than one element?Yes — the signal is not tied to a target. Register listeners on window, document and any number of elements with the same signal, and a single abort() removes all of them. That is the main practical advantage over per-listener removeEventListener calls.
- Does { once: true } help you remove a listener that has not fired yet?No. Until the event fires, a once listener is an ordinary registration, and once is ignored when matching a removal. To cancel it early you need its callback reference and matching capture flag for removeEventListener, or you register it with a signal as well.
saying these in an interview costs you the question
- Thinks once removes the listener after the callback returns
- Reuses one AbortController across several mount cycles
- Believes abort() can be undone to re-enable listeners
- Assumes signal only works for a single event target
- Says once must still be paired with removeEventListener