skip to content

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?

level: seniorimportance: should knowfreq 38%

answer

  1. the output depends on hidden input
  2. pure: inputs never change
  3. nothing checks an OnPush view
  4. make now an argument

basics

~20 s

The 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 s

The 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 lines
ts
import { 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

for a junior

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.

for a middle

Explain why pure: false alone fails under OnPush and zoneless, and what triggers a view check in Angular 22.

for a senior

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.

for a principal

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