In RxJS, how would you drive a smooth countdown animation with animationFrameScheduler, and why does interval(16, animationFrameScheduler) not stay in step with frames?
answer
- zero delay means next repaint
- positive delay means a timer
- frames are not a clock
- time left, not frames counted
- end the loop by completing
basics
~20 sUse interval(0, animationFrameScheduler) or animationFrames() to emit once per frame, compute remaining time from a clock, and end with takeWhile. A positive delay makes animationFrameScheduler fall back to a timer, so interval(16, ...) is not frame-aligned.
solid answer
~40 s`animationFrameScheduler` runs a **zero-delay** task inside a `requestAnimationFrame` callback, just before the browser's next repaint. With a **delay above zero** it falls back to `asyncScheduler` behaviour, so `interval(16, animationFrameScheduler)` is driven by a roughly 16 ms timer that drifts against the display's real frame rate, producing doubled or skipped frames. For a smooth countdown, emit once per frame with `interval(0, animationFrameScheduler)` (or the `animationFrames()` creation function), map each emission to the **remaining time** computed from a clock rather than from how many frames have passed, and finish with `takeWhile(remaining => remaining > 0, true)` so the stream emits its final zero and completes. Completing or unsubscribing drops the pending frame task, so an abandoned countdown stops requesting frames.
code
ts · 17 linesimport { animationFrameScheduler, defer, interval, map, takeWhile } from 'rxjs';
export function countdown(durationMs: number) {
return defer(() => {
const start = animationFrameScheduler.now();
return interval(0, animationFrameScheduler).pipe(
map(() => Math.max(0, durationMs - (animationFrameScheduler.now() - start))),
takeWhile((remainingMs) => remainingMs > 0, true),
);
});
}
const ring = document.querySelector<HTMLElement>('.ring')!;
const sub = countdown(10_000).subscribe((remainingMs) => {
ring.style.setProperty('--progress', String(remainingMs / 10_000));
});
// Leaving early: sub.unsubscribe() drops the pending frame task.go deeper
Recall that animationFrameScheduler runs work just before the browser repaints and that animations should read elapsed time, not count frames.
Explain the zero-delay versus positive-delay rule and why interval(0, animationFrameScheduler) or animationFrames() is the frame-aligned source.
Build the countdown end to end: clock-based remaining time, inclusive takeWhile, per-subscriber start via defer, and teardown that drops the pending frame.
Decide when an RxJS frame loop is worth it versus CSS animations or a framework animation API, weighing control, testability and main-thread cost.
## What animationFrameScheduler does `animationFrameScheduler` is the RxJS scheduler for visual work. Its behaviour depends on the delay a task is scheduled with: - **Zero delay**: the task is queued and a single `requestAnimationFrame` is requested for the whole queue, so the work runs **just before the next repaint**. Rescheduling from inside that work lands in the following frame. - **Delay above zero**: the scheduler **falls back to `asyncScheduler` behaviour** and uses a timer. The work runs when the timer fires, not in an animation-frame callback. That second rule is the trap. `interval(16, animationFrameScheduler)` schedules every tick with a 16 ms delay, so it is a timer-driven stream that merely uses the animation-frame scheduler's class. Timers and the display's refresh are unrelated clocks: on a 60 Hz screen some frames get two ticks and some none, and on a 120 Hz screen roughly half the frames get nothing. The result is visible jitter. ## Emitting once per frame There are two frame-aligned sources in RxJS 7: 1. **`interval(0, animationFrameScheduler)`** reschedules itself with zero delay, so it emits an incrementing counter once per frame. 2. **`animationFrames()`** is a creation function that emits `{ timestamp, elapsed }` on every frame, where `elapsed` is measured from the moment of subscription with `performance.now()` by default. Either gives you a tick per frame. What they must not give you is the **time**. ## Why the countdown must read a clock A frame counter is not a clock: - Displays refresh at different rates, so "60 frames" is one second on one screen and half a second on another. - A busy main thread drops frames. - Browsers pause or heavily throttle animation frames in background tabs. A countdown that decrements per frame therefore runs at the wrong speed and stalls in a hidden tab. Instead, capture a start time on subscription and compute `remaining = duration - (now - start)` on every frame. If frames are dropped, the next frame simply shows the correct remaining time; when a hidden tab becomes visible again, the countdown jumps to the right value instead of resuming where it froze. ## Which clock to read `animationFrameScheduler.now()` returns the scheduler's clock, which by default is `Date.now()`. That is adequate for a countdown measured in seconds, and it keeps the stream testable because a virtual-time scheduler can replace it. `animationFrames()` instead measures `elapsed` with `performance.now()`, a monotonic clock that does not jump if the system time is adjusted, which suits long-running or precise animations. Whichever you pick, read it once per frame and derive everything visual from it: the ring's fill, the digits shown, the colour change in the last few seconds. ## Ending the animation - **Natural end**: `takeWhile((remaining) => remaining > 0, true)` passes values while time is left and, thanks to the `inclusive` flag, also emits the final `0` before completing. Completion unsubscribes the source, which removes the pending frame task. - **Early end**: if the user navigates away, unsubscribing the handle (or a teardown operator placed last) does the same. The scheduled action is removed from the queue, and the frame request is cancelled when nothing else is waiting on it. - **Laziness**: wrap the start-time capture in `defer` so each subscriber gets its own start time instead of one captured when the function was called. ## observeOn as the alternative for timer sources If values come from a timer or a data stream and only the **delivery** should align with rendering, `observeOn(animationFrameScheduler)` hands each notification to the next frame. The source keeps its own timing; only the moment the observer sees the value moves. | Approach | Frame-aligned emissions | Suited to | |---|---|---| | `interval(16, animationFrameScheduler)` | no, timer-driven | nothing; it is the bug | | `interval(0, animationFrameScheduler)` | yes | per-frame animation loops | | `animationFrames()` | yes, with elapsed time | per-frame loops needing time | | `observeOn(animationFrameScheduler)` | delivery only | rendering values from other sources | ## Testing Because the countdown takes its time from a scheduler's clock and its ticks from a scheduler, it can be driven by a virtual-time scheduler in tests rather than by real frames.
- What does the true argument in takeWhile(remaining => remaining > 0, true) change for the countdown?It makes `takeWhile` inclusive: the first value that fails the predicate, here the final `0`, is emitted before the stream completes. Without it the last frame the user sees would show a small positive remainder instead of zero.
- Why wrap the start-time capture in defer()?Without `defer`, the start time is read once, when `countdown()` is called, and every later subscription shares it, so a countdown subscribed a minute later would start already finished. `defer` runs the factory per subscription, giving each subscriber its own start time.
saying these in an interview costs you the question
- interval(16, animationFrameScheduler) emits exactly once per 60 Hz frame.
- Counting frames is an accurate way to measure elapsed time.
- observeOn(animationFrameScheduler) makes the source itself tick on frames.
- A frame loop stops by itself when its view is removed from the page.
- animationFrameScheduler always uses requestAnimationFrame, whatever the delay.