Angular Event Replay: Handling Clicks Before Hydration [2026]

Link copied
Angular Event Replay: Handling Clicks Before Hydration [2026]

Angular Event Replay: Handling Clicks Before Hydration [2026]

A server-rendered page looks finished long before it is. The HTML arrives, the browser paints a complete page with buttons, menus and an "Add to cart" link, and the user starts clicking. But the JavaScript that handles those clicks may still be downloading, parsing or hydrating. Without help, those early clicks simply disappear: the button looks broken, the user clicks again, and by the time the app is ready it may handle the second click, or both, or neither.

This is lesson 9.4 of the Angular Tutorial. Lesson 9.3 covered how hydration reuses the server's DOM; this lesson covers the window before hydration finishes. You'll learn how Angular's event replay captures early interactions and replays them, how it is enabled in Angular v22, what it does and doesn't replay, and how to shrink the window itself so replay has less to do.

The gap between visible and interactive #

Phase What the user sees Can they interact?
HTML received and painted A complete-looking page Only with native browser behaviour (links, text selection, form fields)
JavaScript downloading and parsing Same page Angular listeners don't exist yet
Bootstrap and hydration running Same page Listeners are being attached, part by part
Hydration complete Same page Fully interactive

On a fast laptop that gap may be a few hundred milliseconds. On a mid-range phone on a slow network it can be several seconds, and those users are the most likely to tap while they wait. The gap shows up in field data as poor Interaction to Next Paint and in support tickets as "the button doesn't work the first time".

How event replay works #

Event replay is built on a small event-dispatch library (originally called JSAction) that Angular includes with server-rendered pages. It works in three steps:

  1. Mark. While rendering on the server, Angular records which elements have event listeners in your templates and annotates them in the HTML.
  2. Capture. A tiny script included inline in the server-rendered page installs global listeners as soon as the HTML is parsed, long before your application bundles run. When the user clicks an annotated element, the event is recorded in a queue.
  3. Replay. When hydration attaches the real listeners, Angular dispatches the queued events to them in order, as if they had just happened.

From the user's point of view, the first click "worked", just a little late. From your component's point of view, the handler ran normally.

<!-- Your template: nothing special required -->
<button type="button" (click)="addToCart(product().id)">Add to cart</button>

If the user taps this button while the bundle is still downloading, addToCart runs once hydration finishes. No duplicate click, no lost click.

Enabling it in Angular v22 #

In Angular v22, incremental hydration is on by default whenever you use provideClientHydration(), and event replay is enabled along with it. A standard SSR setup already has it:

// app.config.ts
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideClientHydration(),          // includes incremental hydration and event replay
  ],
};

If you opt out of incremental hydration, event replay goes with it, so add it back explicitly:

import {
  provideClientHydration,
  withEventReplay,
  withNoIncrementalHydration,
} from '@angular/platform-browser';

provideClientHydration(withNoIncrementalHydration(), withEventReplay());

Older projects often have provideClientHydration(withEventReplay()) from earlier versions. That still works, but with the v22 defaults the withEventReplay() call is redundant and can be removed. Likewise withIncrementalHydration() is now deprecated, because it is the default.

Configuration Incremental hydration Event replay
provideClientHydration() On On
provideClientHydration(withEventReplay()) On On (call is redundant)
provideClientHydration(withNoIncrementalHydration()) Off Off
provideClientHydration(withNoIncrementalHydration(), withEventReplay()) Off On

What is replayed, and what isn't #

Event replay covers native browser events, such as click, mouseover and focusin, that match listeners Angular registered from your templates. It's important to know its edges:

Situation Replayed? Notes
(click), (keydown) and similar on elements in your templates Yes The main use case
Listeners you add imperatively (addEventListener in afterNextRender) No Angular doesn't know about them on the server
Listeners inside a component marked ngSkipHydration No reliable replay That subtree is re-rendered, not hydrated
Events on elements with no listener No Nothing to replay them to
Native default actions (following a link, typing into an input, toggling a checkbox) Not needed The browser does these without JavaScript
Handlers that call event.preventDefault() Handler runs, but too late to prevent anything The browser's default action already happened when the event first fired

The last row is the one that bites. Consider a link that is enhanced with a click handler:

<!-- Before hydration, a click follows href immediately; the replayed handler can't stop it -->
<a href="/compare" (click)="openCompareDrawer($event)">Compare</a>
openCompareDrawer(event: MouseEvent) {
  event.preventDefault();        // has no effect on a replayed event
  this.drawerOpen.set(true);
}

If the user clicks before hydration, the browser navigates to /compare as a normal full-page load. That's not a disaster: the target page exists and works, which is the point of using a real href. But design for it. Use a real <button> for actions that aren't navigation, and keep links that work as links.

Router links behave well here for the same reason. routerLink renders a real href on the server, so a click before hydration does a full navigation to the right page, and a click after hydration does a client-side navigation.

Writing handlers that tolerate replay #

A few habits make replayed events behave the same as live ones:

  • Don't depend on timing. A replayed handler runs a few hundred milliseconds or more after the actual click. Avoid logic like "if clicked within 300 ms of page load".
  • Make actions idempotent where you can. If the user clicked twice because nothing seemed to happen, both clicks may be replayed. Disable the button while the request is in flight, or ignore duplicates on the server.
  • Don't rely on event.target state from the moment of the click. The handler runs after hydration; read the current state from your signals.
  • Give feedback quickly once handlers run. A spinner or optimistic update makes a delayed response feel deliberate.
@Component({
  selector: 'app-add-to-cart',
  template: `
    <button type="button" [disabled]="pending()" (click)="add()">
      {{ pending() ? 'Adding…' : 'Add to cart' }}
    </button>
  `,
})
export class AddToCart {
  private cart = inject(CartService);
  productId = input.required<string>();
  pending = signal(false);

  async add() {
    if (this.pending()) return;          // a second replayed click is ignored
    this.pending.set(true);
    try {
      await this.cart.add(this.productId());
    } finally {
      this.pending.set(false);
    }
  }
}

Shrinking the gap #

Event replay hides the problem; it doesn't remove it. The best experience is a short gap, so replay rarely has anything to do. The levers, roughly in order of impact:

Lever Effect on the gap Where it's covered
Smaller initial bundle (lazy routes, @defer) Less JavaScript to download and parse before hydration Lesson 5.6, lesson 8.5
Incremental hydration Only the parts users need are hydrated up front Lesson 9.5
Transfer cache No repeated HTTP calls before the page settles Lesson 9.6
No heavy work during bootstrap Long tasks in constructors or APP_INITIALIZER-style setup block hydration Move to afterNextRender or later
Third-party scripts loaded later Analytics and ads compete for the main thread Load them after hydration or on idle

To see the gap on your own pages, record a performance trace in Chrome DevTools with CPU and network throttling, and compare the time of the first contentful paint with the moment the Angular hydration work finishes on the main thread. If the gap is more than a second or two on a throttled profile, users will notice, with or without replay.

Gotchas #

Symptom Cause Fix
Early clicks are lost Event replay disabled, typically withNoIncrementalHydration() without withEventReplay() Remove the opt-out or add withEventReplay()
Link navigates instead of opening a drawer when clicked early preventDefault() in a replayed handler is too late Use a <button> for actions; keep links for navigation
Action runs twice after a slow load User clicked twice, both clicks replayed Guard with a pending flag; make the server endpoint idempotent
Clicks on a chart or map widget ignored before load Listeners attached with addEventListener, not templates Accept it, or show a loading state until the widget initialises
Clicks inside a skipped component not replayed Component uses ngSkipHydration Fix the component's hydration issue so the flag can be removed
Page is visible for several seconds before anything responds Large bundle or heavy bootstrap work Defer code, use incremental hydration, move work out of startup

What's next #

Lesson 9.5 takes the biggest lever from the table above: incremental hydration with @defer (hydrate on ...) triggers, which lets server-rendered sections stay dehydrated, with no JavaScript loaded for them, until the user scrolls to them, hovers over them or interacts with them.

Try it yourself #

Throttle the network to "Slow 4G" and the CPU by 4x in Chrome DevTools, load a server-rendered page of your app, and click the main call-to-action button as soon as it appears. Check that the action happens exactly once. Then search your templates for (click) handlers on <a href> elements that call preventDefault(), and decide whether each should be a button.

YouWe upgraded to v22 and our app config still has provideClientHydration(withEventReplay(), withIncrementalHydration()). Do we need to change anything?
Claude · used search_documentationNothing is broken, but both features are now redundant. Since v22, provideClientHydration() enables incremental hydration by default, and event replay is enabled along with it. withIncrementalHydration() is deprecated, so you can simplify app.config.ts to provideClientHydration() and get the same behaviour. The only time you would keep withEventReplay() is if you opt out with withNoIncrementalHydration(). After the change, load a server-rendered page with throttling and click a button before the bundle finishes; the handler should still run once hydration completes.

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *