skip to content

A table renders 500 rows, each with an Angular directive holding a `(document:click)` host listener, and every click now feels slow; why, and how would you fix it?

level: seniorimportance: should knowfreq 28%

answer

  1. global targets are not coalesced
  2. one native listener per instance
  3. each firing marks views dirty
  4. mount it only where it matters

basics

~20 s

Each instance registers its own document listener, so one click runs 500 handlers, each marking its view and ancestors dirty and scheduling change detection. Mount the directive only on the open menu, or share one listener.

solid answer

~50 s

Angular coalesces several listeners for the same event on the same element into one native listener, but it deliberately does not do that for global targets such as `document:`. So 500 directive instances mean 500 native listeners on `document`, and every click invokes all of them. Each invocation goes through Angular's listener wrapper, which marks the directive's view and every ancestor dirty and notifies the scheduler, so in a zoneless app the click also leads to a change-detection pass that now has to visit all 500 row views even if they are `OnPush`. The fix is to stop paying for instances that do not need to listen: put the directive only on the open menu (render it inside `@if (open())`), or keep one shared listener in a service that notifies just the active menu. A manual listener added only while open is another option, with cleanup through `DestroyRef`.

go deeper

for a junior

Recall that a document: host listener is registered once per directive instance, so many instances mean many listeners.

for a middle

Explain that each listener firing marks its view and ancestors dirty and schedules change detection, even when the handler does nothing.

for a senior

Diagnose with a profile, then fix by mounting the directive only while open or sharing one listener, and verify the check count drops.

for a principal

Establish a rule for shared UI primitives: global listeners are attached lazily, and overlay dismissal is centralised rather than repeated per instance.

## The symptom A data table shows a row-actions menu on each of 500 rows. Every row's menu element carries a click-outside directive that declares `'(document:click)': 'onDocumentClick($event)'` in its `host` object. Nothing is open, yet every click anywhere on the page, including typing into a search field and clicking a checkbox, takes noticeably longer, and a performance profile shows hundreds of short handler calls followed by a large change-detection pass. ## Why it happens Three mechanisms combine. 1. **One native listener per instance.** For ordinary listeners, Angular coalesces several handlers for the same event on the same element into a single native listener. The source explicitly skips that coalescing for alternative targets such as `document:click`, to keep backward-compatible behaviour. So each directive instance calls the renderer's `listen()` on the document, and the document ends up with 500 click listeners. 2. **Every handler marks views dirty.** Each listener is wrapped by Angular. Before calling your method, the wrapper marks the directive's view, and every ancestor up to the root, for checking. With 500 handlers, all 500 row views and their ancestors are marked, even though every handler concludes "the click was not near me" and does nothing. 3. **Every handler notifies the scheduler.** In a zoneless application, the default since v21, the listener notification schedules an application tick. Several notifications in one event are batched into one pass, but that pass now has to refresh 500 dirty row views. The `OnPush` default in v22 does not help, because the views really are marked dirty. The work is proportional to the number of **mounted** instances, not the number of open menus. ## Fixes | Fix | How | Trade-off | | --- | --- | --- | | Mount only when needed | Put the directive on the menu panel inside `@if (open())` | Simplest; at most a few instances exist | | One shared listener | A root service listens once and notifies only the active menu | Central place for dismissal rules; more code | | Manual listener while open | Add with `Renderer2.listen` when opening, call the unlisten function on close and in `DestroyRef.onDestroy` | No dirty marking from the listener; cleanup is yours | | Event delegation on the table | One listener on the table decides which row is affected | Fits row actions; does not replace click-outside | The first fix is almost always enough: a closed menu has nothing to dismiss, so its directive should not exist. Rendering the panel inside `@if` also removes its DOM, and Angular removes the document listener when the view is destroyed. ## Sketch of the shared-listener option When menus must stay mounted, for example because they animate or keep state while closed, a root service can own the only listener: 1. The service registers one document click listener lazily, the first time any menu opens, using `Renderer2.listen` or `addEventListener`. 2. Each menu registers itself with the service when it opens and unregisters when it closes or is destroyed. 3. On a click, the service checks only the registered open menus and tells those whose element does not contain the click to close. 4. When no menu is open, the service removes the listener again. This keeps the per-click cost proportional to the number of open menus, usually one, and gives the application one place to define dismissal rules such as ignoring clicks on the toggle button. ## How to confirm it - In the browser's developer tools, inspect the document's registered event listeners and count the click handlers. - Record a performance profile of a single click and look for many short calls to the directive's handler followed by a long change-detection task. - Repeat the profile after the fix: the handler calls should drop to one or none, and the change-detection task should shrink accordingly. ## Wider lessons - **Global host listeners scale with instance count.** The same applies to `window:resize` and `window:scroll` listeners in list items, which are even more frequent. - **Automatic dirty marking is a convenience with a cost.** It exists so `OnPush` components update after their own events, but it runs whether or not the handler changed anything. - **Handlers should bail out early.** Even with fewer instances, check the cheap condition first, such as whether the menu is open, before touching the DOM. - **Measure before and after.** A profile of one click is the evidence that the fix worked, rather than a guess based on the code.

  • Why doesn't `OnPush` stop the 500 row views from being checked after each click?
    `OnPush` skips views that are not dirty, but Angular's listener wrapper explicitly marks the listening directive's view and its ancestors dirty before running the handler. Every instance's handler runs on every document click, so every row view is dirty when change detection runs.
  • If you switch to `Renderer2.listen` on the document while the menu is open, what must you remember?
    `listen()` returns an unlisten function. Call it when the menu closes and in a `DestroyRef.onDestroy` callback, or the listener survives the menu. Such a listener is not wrapped by Angular, so the view is not marked dirty; if the handler sets a signal read by the template, that still schedules an update.

saying these in an interview costs you the question

  • Angular merges all document:click host listeners into one native listener.
  • OnPush prevents views whose host listener fired from being checked.
  • A handler that returns early without changing state causes no change-detection work.
  • The cost depends on how many menus are open, not how many are mounted.
  • Moving to zoneless change detection removes the cost of global host listeners.