My portfolio’s hero has a typing effect that cycles through job titles. One day I noticed it was stuck on a single letter. The code looked fine: a setTimeout loop updating a string. The cause was that the app runs without Zone.js.
What Zone.js used to do for you
Classic Angular patches browser APIs like setTimeout and addEventListener, so after any callback it runs change detection automatically. Zoneless Angular drops that patching. It re-renders only when it is notified: a template event, an input change, or a signal that a template reads.
So a plain class field changed inside setTimeout updates the value in memory, but nothing tells Angular to update the screen.
The fix: signals
// before: never re-renders when changed from a timer
currentRole = '';
// after: the template re-renders whenever the signal changes
currentRole = signal('');
this.currentRole.set(current.substring(0, this.charIndex + 1));In the template, read it as currentRole(). Any code path that updates state from outside an Angular event (timers, IntersectionObserver callbacks, requestAnimationFrame) should write to a signal.
The opposite problem: too many re-renders
While fixing that, I found the reverse. A @HostListener for window scroll or pointermove counts as an Angular event, so it schedules change detection for the whole app on every scroll tick or mouse move. For a navbar that only needs to know whether you have scrolled past 50px, that is wasted work.
- Use a passive addEventListener in ngOnInit and remove it in ngOnDestroy.
- Write the result to a signal; setting a signal to the same value does nothing, so the UI only updates when the threshold is crossed.
- Do per-frame visual work (like a 3D tilt) directly on the DOM element, outside Angular.
NgZone calls become no-ops
Code copied from zone-based examples often wraps work in ngZone.run or runOutsideAngular. Without Zone.js these do nothing, and leaving them in hides the real change-detection model. I removed them and made signals the only way state reaches the view.