A custom Angular timeAgo pipe keeps showing '3 minutes ago' long after; why does it never update, and how would you design it to refresh correctly?
answer
- the output depends on hidden input
- pure: inputs never change
- nothing checks an OnPush view
- make now an argument
basics
~20 sThe pure pipe reads Date.now() internally, but its only input never changes, so transform never re-runs. Pass a shared now signal as an argument, updated by one timer, so the pure pipe re-runs when time moves.
solid answer
~50 sThe pipe's result depends on the current time, which is not an argument, and pure pipes only re-run when the value or arguments change, so the first text is cached. Setting `pure: false` alone does not fix it in v22: components are `OnPush` and apps are zoneless, so nothing checks the view as time passes. My preferred design is a root `Clock` service with a `now` signal updated every 30 seconds, and `{{ createdAt | timeAgo: clock.now() }}`. The signal read marks the view for refresh, the changed argument re-runs the pure pipe, there is one timer for the whole app, and the pipe is a deterministic function that is easy to test. The alternative, like `AsyncPipe`, is an impure pipe that calls `markForCheck()` from its own timer and clears it in `ngOnDestroy`, with one timer per usage.
code
ts · 23 linesimport { DestroyRef, Injectable, Pipe, PipeTransform, inject, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Clock {
readonly now = signal(Date.now());
constructor() {
const id = setInterval(() => this.now.set(Date.now()), 30_000);
inject(DestroyRef).onDestroy(() => clearInterval(id));
}
}
@Pipe({ name: 'timeAgo' })
export class TimeAgoPipe implements PipeTransform {
transform(timestamp: number, now: number): string {
const minutes = Math.floor((now - timestamp) / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} min ago`;
return `${Math.floor(minutes / 60)} h ago`;
}
}
// Template: {{ comment.createdAt | timeAgo: clock.now() }}
// Component: readonly clock = inject(Clock);go deeper
Recall that a pure pipe only re-runs when its input or arguments change, so a value computed from the clock inside it goes stale.
Explain why pure: false alone fails under OnPush and zoneless, and what triggers a view check in Angular 22.
Design the refresh: a shared now signal passed as an argument, or an impure pipe with markForCheck and cleanup, and weigh timers, testability and SSR.
Treat time as an explicit input across the UI, one clock source with a refresh policy, so every relative timestamp behaves consistently.
## The bug A comment list shows "posted 3 minutes ago" and still says so half an hour later: ```html <span>{{ comment.createdAt | timeAgo }}</span> ``` `TimeAgoPipe` computes the text from `Date.now()` inside `transform`. The output depends on something that is **not an argument**: the current time. ## Why it is stuck The pipe is **pure** by default. Angular re-runs a pure pipe only when its value or arguments change by `Object.is`. `comment.createdAt` never changes, so `transform` never runs again, however many times the view is checked. The first result is cached for that binding. ## Why "just make it impure" is not enough With `pure: false`, `transform` runs **on every check of the view**, so it would read a fresh `Date.now()`. But in Angular 22 nothing checks the view on its own: - components are `OnPush` by default, and new applications are **zoneless**; - the passage of time is not an event; no input changes, no template listener fires, no signal is written. So an impure pipe with no trigger is just as stuck. It also re-runs its date arithmetic on every check once something does trigger one. ## Design 1 (recommended): make time an argument Put the current time in **one shared signal**, and pass it to a pure pipe: ```ts @Injectable({ providedIn: 'root' }) export class Clock { readonly now = signal(Date.now()); constructor() { const id = setInterval(() => this.now.set(Date.now()), 30_000); inject(DestroyRef).onDestroy(() => clearInterval(id)); } } ``` ```html <span>{{ comment.createdAt | timeAgo: clock.now() }}</span> ``` What happens every 30 seconds: 1. `Clock` writes a new value to the `now` signal; 2. every template that read `clock.now()` is marked for refresh, which works with `OnPush` and zoneless; 3. the pipe's argument changed, so the pure pipe runs `transform` again and returns the new text. The benefits: **one timer** for the whole application, the pipe stays pure and trivially testable (pass any `now`), and the refresh rate is a single decision. ## Design 2: an impure pipe that schedules its own refresh This is the shape `AsyncPipe` follows. The pipe injects `ChangeDetectorRef`, starts a timer, calls `markForCheck()` when the text should change, and clears the timer in `ngOnDestroy`: - `markForCheck()` is one of the notifications that schedule change detection in a zoneless application; - the pipe must be `pure: false`, or the check would still reuse the cached output; - every usage creates its **own pipe instance and its own timer**, so a list of 200 comments runs 200 timers. It works and keeps templates unchanged, but it is harder to test and costs more at scale. ## Comparing the options | Approach | Refreshes in v22 default setup | Timers | Testability | |---|---|---|---| | Pure pipe reading `Date.now()` | No | 0 | Non-deterministic | | Impure pipe, no timer | No | 0 | Non-deterministic | | Pure pipe + shared `now` signal argument | Yes | 1 | Deterministic | | Impure pipe + own timer + `markForCheck()` | Yes | One per usage | Needs fake timers | ## Details a senior should mention - **Choose a sensible interval.** Minute-level text does not need a one-second timer; many implementations update more often for recent items and rarely for old ones. - **Clean up.** Any timer a pipe or service starts must be cleared on destroy; a pipe's `ngOnDestroy` runs when its view is destroyed. - **Server rendering.** Start the timer only in the browser; a relative time computed on the server is stale by the time the page is interactive anyway. - **Keep `transform` a pure function of its arguments.** That is the property the design restores: the output depends on `createdAt` and `now`, both visible to Angular.
- How do you unit-test the pure timeAgo pipe?Instantiate it directly and call `transform(createdAt, now)` with fixed numbers, such as a timestamp and that timestamp plus 180000 ms, expecting '3 min ago'. No fake timers or TestBed are needed, because time is an argument.
- In the impure design, why must the pipe call markForCheck() rather than just re-computing?An impure pipe only runs when its view is checked. With OnPush and zoneless defaults, nothing checks the view as time passes; `markForCheck()` marks the view and schedules change detection, so the pipe then runs and returns fresh text.
It is like a noticeboard that only gets rewritten when someone brings a new sheet: a clerk who silently checks the wall clock never gets asked. Passing now as an argument is the office bell that rings every half minute and hands everyone a fresh sheet.
saying these in an interview costs you the question
- Believes pure: false alone makes the text refresh in a zoneless OnPush app
- Reads Date.now() inside a pure pipe and expects updates
- Starts a timer per pipe instance without clearing it on destroy
- Believes minute-level text needs a one-second refresh timer
- Thinks a pure pipe re-runs whenever the component is checked