docs(performance): record the idle work audit (#1719)

This commit is contained in:
4gray authored and GitHub committed 2026-09-29 07:03:01 +02:00
1 parent 40a08a307d
commit de7b19aee2
2 files changed
+265

No files matched your search

@@ -0,0 +1,259 @@
# Idle work audit (2026-09)
One-off investigation for plan item D3 ("hidden work audit") of the
performance journeys plan. The question: what does IPTVnator do while the
user does nothing? This document records the measurement and the findings.
Nothing was changed. Every row marked **own thread** is a candidate for a
separate, benchmarked follow-up under the
[performance journeys](performance-journeys.md) process.
**Historical results.** The capture ran on `8ebb7e342` (2026-09-26). The
cinematic rotating dashboard hero (#1725, `887ac64`) landed afterwards, so the
measured counts and the "all dashboard idle work" conclusions below describe
the dashboard without it. The hero is accounted for separately, from code
reading, under
[Added after the measurement](#added-after-the-measurement-rotating-hero).
## Result in brief
- **The main process starts no periodic work at idle.** Across four
two-minute windows its probes saw zero timers fired, zero outbound HTTP
requests and zero file writes. With a fresh profile they also saw zero IPC
and zero SQL. With recent live channels, the dashboard's renderer timers
caused 1–2 IPC calls and 2–4 SQL statements per window (see the table
below). Workers were not probed directly: the SQL trace covers the
database worker, and reading the code found worker timers only inside
running operations. The EPG refresh, download manager, source health
probe, auto-updater, remote-control server and connectivity guard are all
demand-driven or off by default.
- **All idle work is in the renderer, on the dashboard.** With a fresh
profile, two RxJS intervals (30 s and 60 s) cause 12 app-wide Angular
change-detection passes per two minutes. With recently watched live
channels, a third interval adds an IPC and SQL lookup about every
minute. It also triggers a 24-frame, full-document layout animation every
30 s.
- **A minimized window does exactly the same work as a visible one.** The
main window sets `backgroundThrottling: false`
([app.ts:125](../../apps/electron-backend/src/app/app.ts)). Chromium
therefore neither throttles timers nor reports the page as hidden. No
renderer code can pause itself when the window is minimized, because
`document.visibilityState` stays `visible`.
The three worst offenders, in order:
1. `backgroundThrottling: false` on the main window: every renderer timer,
rAF and CSS transition keeps running when minimized, and the page
visibility API is blind to minimization.
2. The dashboard live-EPG 30 s heartbeat with recent live cards: an IPC and
SQL batch about every minute, rebuilt rails, and a 0.4 s `width`
transition on progress bars. Each tick costs about 24 full-document
layouts plus GPU frames. This state has 17–76× the GPU time of the fresh
profile.
3. The always-on 30 s portal live-EPG heartbeat plus the 60 s source-expiry
tick: 12 app-wide change-detection passes per two minutes, even when there
is nothing to update. The 60 s tick also rebuilds the sources rail, which
schedules a scroll reset and re-observes every card.
## Method
- **Build.** `pnpm nx run electron-backend:build-e2e` on `8ebb7e342`. It
is unoptimized, with source maps and Angular dev mode. Dev mode runs every
change detection twice (the `checkNoChanges` pass), so per-firing
renderer costs below are upper bounds for a production build. Timer, rAF,
IPC, SQL, HTTP, layout and DOM-mutation counts do not depend on dev mode.
The Angular change-detection and template-update counts are dev-build
counts: a production build skips the `checkNoChanges` pass, so it performs
fewer template updates per tick.
- **Servers.** The Xtream mock ran on loopback port 3411 with its control
plane enabled. A second E2E worktree already held 3310. A throwaway loopback
HTTP server served a 60-channel M3U with `url-tvg` and logo URLs, and it
logged every request.
- **Profile.** A fresh data directory seeded through the E2E fixture
helpers: one M3U URL source and one Xtream source (mock `user1`). The
"recent live" variant also opened two M3U and two Xtream live channels
during seeding.
- **Measurement launch.** The app was relaunched against the seeded profile
with `IPTVNATOR_TRACE_STARTUP=1`, `IPTVNATOR_TRACE_IPC=1` and
`--remote-debugging-port=9422`, because port 9222 was held by another
Electron. The app landed on `/workspace/dashboard`. After a 20 s settle
window came 120 s visible and focused, then 120 s minimized. The measured
state was `isMinimized() === true` and `isVisible() === false`.
- **Main-process probe.** A bootstrap entry point, the same pattern as
`playlist-refresh-write-gate.ts`, wrapped these before requiring
`dist/apps/electron-backend/main.js`:
- `ipcMain.handle/on`
- `webContents.send/postMessage`
- global `setTimeout/setInterval/setImmediate`
- `http(s).request/get`, `fetch`, `net.request/fetch`
- `fs` write and rename calls
- `Worker.postMessage`
SQL came from the existing trace channel on stdout.
- **Renderer probe.** Injected before any page script with
`Page.addScriptToEvaluateOnNewDocument`. It wrapped timers, rAF,
IndexedDB writes, `localStorage`/`sessionStorage`, fetch and XHR. It also
counted `Zone.prototype.runTask` per zone and source, and change-detection
ticks through `ng.ɵsetProfiler` (event `ChangeDetectionStart`). DOM
mutations came from a document-wide `MutationObserver`. Separately, a CDP
`Tracing` profile and `Performance.getMetrics` covered each window, and
`app.getAppMetrics()` gave per-process CPU.
- **Two passes.** An instrumented pass collected traces and a 1 kHz
main-process CPU profile. A light pass left the profiler, tracing and
network capture off, so its per-process CPU numbers are not inflated by the
instruments. The instrumented pass had browser-process CPU of about 2.5 s
per window and 550 wakeups/s, almost all of it the sampling profiler.
Playwright pins every page it attaches to as visible: `Emulation.setFocusEmulationEnabled`
raises the page's capturer count. The minimized windows above were measured
through Playwright. That is faithful for this build, because
`backgroundThrottling: false` keeps the page visible anyway. Measuring a build
where the window can become hidden needs a launch without Playwright over raw
CDP. The follow-up PRs do that.
Harness artifacts that were excluded:
- The E2E fixture's own 60 Hz rAF frame counter (`startRendererFrameCapture`),
which was cancelled after launch.
- An extra `about:blank` navigation before the app URL, used to register
the renderer probe.
- The probe's own stdout tee.
- Playwright `evaluate` round-trips at window boundaries.
The harness was a one-off and is deliberately not committed, so the figures
below cannot be rerun from the repository as is. They are observations from
this run, and the method above describes how to rebuild the harness. The
maintained way to measure is the journey harness in
[performance journeys](performance-journeys.md); an idle journey there is the
path to making these counters reproducible and guarded.
## Measurements
Counts per 120 s window. CPU is the light pass, taken from
`getAppMetrics().cpu.cumulativeCPUUsage` deltas.
| Counter | Fresh, visible | Fresh, minimized | Recent live, visible | Recent live, minimized |
| --- | --- | --- | --- | --- |
| Renderer timer firings | 6 | 6 | 10 | 10 |
| Renderer rAF callbacks | 4 | 4 | 12 | 12 |
| Angular CD ticks | 12 | 12 | 29 | 30 |
| DOM mutation records | 0 | 0 | 12 | 12 |
| Layouts (`LayoutCount`) | 0 | 0 | 139 | 129 |
| IndexedDB / Storage writes | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 |
| Renderer fetch/XHR, CDP network | 0 | 0 | 0 | 0 |
| IPC renderer→main (excluding trace echo) | 0 | 0 | 1 | 2 |
| IPC main→renderer | 0 | 0 | 0 | 0 |
| SQL statements (main) | 0 | 0 | 2 | 4 |
| Main timers fired / HTTP / fs writes | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 |
| Renderer process CPU | 158 ms¹ | 11 ms | 308 ms | 463 ms |
| GPU process CPU | 19 ms | 7 ms | 332 ms | 530 ms |
| Browser (main) process CPU | 214 ms¹ | 67 ms | 180 ms | 281 ms |
¹ The visible window came first, so it also absorbed a one-time post-startup
V8 incremental major GC and Playwright's evaluate calls. The trace shows 798
`V8.GC_MC_INCREMENTAL` steps in the visible window and none in the minimized
one. Steady-state renderer cost for the fresh profile is closer to the
minimized figure.
Per-firing costs, taken from the renderer trace (dev build):
- Each 30 s or 60 s `TimerFire` is 1.6–2.2 ms, with rare outliers of 11–13 ms.
- The rail scroll-reset rAF pair is 0.4–1.5 ms.
- In the recent-live state, each 30 s tick adds about 24 consecutive frames,
each with a full-document `Layout` (30 dirty of 541 objects), `HitTest`,
`UpdateLayer` and two `IntersectionObserver` computations. That is about
0.8–2 ms of renderer main-thread time per frame, or about 25–40 ms per tick.
The GPU adds 45–130 ms per tick.
## Findings
"Evidence" says whether the row was measured at idle or found by reading the
code. Costs are for the dev build unless noted.
### Periodic work observed at idle
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
| --- | --- | --- | --- | --- | --- | --- |
| [app.ts:125](../../apps/electron-backend/src/app/app.ts) `backgroundThrottling: false` | Disables Chromium background throttling and page-visibility changes for the main window. It was added without a stated reason in #1123. | Continuous (amplifier) | Makes every renderer row below cost the same while minimized. Minimized windows still ran 6–10 timers, 4–12 rAF and 129 layouts per 2 min. | **No.** Nothing on the idle path needs full-rate timers while minimized, and it defeats the keep-awake visibility gate (see the next section). | Measured: `isMinimized()` is true while `visibilityState` stays `visible` | **Own thread.** Find the playback or radio case that needed it, then enable throttling or toggle it per active playback through `webContents.setBackgroundThrottling`. Add a minimized idle counter to the journeys. |
| [dashboard-portal-live-epg.presenter.ts:52](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts) `interval(LIVE_EPG_TICK_MS)` feeding the effect at :86 | Heartbeat that calls `DashboardPortalLiveEpgService.sync(wanted)` for Xtream and Stalker live cards | 30 s, always on while the dashboard is mounted | One app-wide CD tick, about 2 ms. With no portal live cards `wanted` is empty, so there is no IPC. | **No** when `wanted` is empty. The tick has nothing to do but still runs a full zone CD pass. | Measured: 4 per 2 min in every state | **Own thread.** Run the interval only while `wanted` is non-empty, outside the Angular zone, and pause on hidden. |
| [workspace-dashboard-rails.component.ts:345](../../libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts) `interval(SOURCE_EXPIRY_TICK_MS)`, read at :753 | Minute heartbeat for source-expiry badges. It recomputes `sourceCards`, which returns a new array. | 60 s, whenever the dashboard is mounted: `toSignal(interval(...))` subscribes at construction, so it also ticks with no recent sources or with that rail disabled; the rail rebuild only follows when recent sources exist | One CD tick of about 2 ms. The new `items` input also fires the rail effects: two chained rAF CD ticks and a `scrollTo(0)` from `scheduleResetToStart` ([dashboard-rail.component.ts:176–187, :317](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts)), and an IntersectionObserver re-observe of every card. | **Partly.** Badges must cross day boundaries, but a minute tick for day-granular badges is excessive. Returning a new array each time also resets a user-scrolled rail. | Measured: 2 timer and 4 rAF per 2 min | **Own thread** (#1722). Schedule the next badge boundary instead of polling, arm nothing when no badge can change, give `sourceCards` a structural `equal`, and reset the rail scroll only when card identity changes. |
| [dashboard-live-epg.presenter.ts:116–132](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `interval(LIVE_EPG_TICK_MS)` with `forkJoin(askScope…)` | "Now on air" lookup for hero, live-favourite and recent-live cards | 30 s, only when live cards exist | `GET_CURRENT_PROGRAMS_BATCH` IPC about every second tick, because of the 60 s program cache, running 2 SQL `SELECT`s. Every emission is a new `Map`, so the live rails rebuild. That adds rAF scroll resets, IO re-observe, and new progress widths (next row). | **Partly.** Progress and "now" titles are a feature. Rebuilding the rails when the programme did not change is not. | Measured: 1–2 IPC and 2–4 SQL per 2 min | **Own thread.** Emit only on programme change, tick on the next programme boundary instead of every 30 s, and pause on hidden. |
| [dashboard-rail.component.scss:256–271](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss) `.rail__art-progress i { transition: width 0.4s ease }` | Animates the live-programme progress bar each time its width binding changes | Every 30 s tick with live cards | About 24 full-document layouts and paints over 0.4 s: about 25–40 ms renderer and 45–130 ms GPU per tick. Twelve `<i>` attribute mutations per 2 min. | **No.** A sub-pixel progress change does not need a layout-driven animation, and it runs while minimized. | Measured: trace shows one rAF, then 24 frames of Layout, HitTest and IO every 30 s | **Own thread.** Animate `transform: scaleX()` (compositor-only), or drop the transition for tick updates. |
| Eager components on every tick: [app.component.ts:54](../../apps/web/src/app/app.component.ts), [workspace-shell.component.ts:52](../../libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts), [epg-progress-panel.component.ts:67](../../libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts), [app-update-notification-panel.component.ts:106](../../apps/web/src/app/app-update-notification-panel.component.ts) | `ChangeDetectionStrategy.Eager` roots re-render their templates on every zone tick | Every tick above | Each of the four templates updated 24 times over the 12 ticks: twice per tick, because dev mode adds the `checkNoChanges` pass | **No** for idle. Nothing in them changes on a timer. | Measured through the `TemplateUpdateStart` profiler events | Fold into plan item C6 (OnPush/zoneless). No separate thread. |
### Added after the measurement: rotating hero
Not in the measured counts above; found by reading the code on master after
#1725.
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
| --- | --- | --- | --- | --- | --- | --- |
| [dashboard-hero.component.scss:558](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animates `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | A layout pass on every animation frame, because `width` is a layout property (not measured) | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Static | **Own thread.** Animate the fill with a compositor-only `transform: scaleX()`, keep the `animationend` advance, and decide whether rotation should pause on an idle or hidden dashboard. |
| [dashboard-live-epg.presenter.ts:121](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `heroLiveCandidates` (lookup) and :191 (pinned portal keys); limits in [dashboard-hero-slides.utils.ts:14–15](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts) | Up to five hero live candidates (three favourites, two recent) join the 30 s XMLTV "now on air" lookup, and are pinned for the portal live-EPG queue even when their rails are hidden or scrolled away | 30 s XMLTV heartbeat while the hero is enabled and any candidate exists; portal sync on the same tick | More lookup keys per `GET_CURRENT_PROGRAMS_BATCH` IPC and SQL, and portal `get_short_epg`-style requests for pinned Xtream/Stalker candidates when stale (not measured) | **Yes** for the programme shown on the hero; the 30 s re-ask of unchanged programmes is not | Static | Covered by #1722: the clock re-asks only when a programme ended, a key has no answer, the answer is five minutes old, or the guide changed, and it pauses while hidden. |
### Checked and not periodic at idle
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
| --- | --- | --- | --- | --- | --- | --- |
| EPG refresh ([epg.events.ts:176–178](../../apps/electron-backend/src/app/events/epg.events.ts)) | There is no scheduler. `FETCH_EPG` and `EPG_CHECK_FRESHNESS` (default `maxAgeHours` 12) run only when the renderer asks. | None | One `FETCH_EPG` at boot with no configured EPG URL. The M3U `url-tvg` was never requested. | Yes | Measured: 0 at idle | None |
| Download manager ([download-transfer.ts:289](../../apps/electron-backend/src/app/events/database/download-transfer.ts), [download-reconnect.ts:104](../../apps/electron-backend/src/app/events/database/download-reconnect.ts)) | Writes progress at most every 500 ms from stream `data` events, and has a 1 s reconnect sleep. It runs only while bytes flow or a reconnect is pending. | Event-driven | One `UPDATE downloads` plus a `DOWNLOADS_UPDATE_EVENT` per progress write during a transfer | Yes (real transfers) | Static; `DOWNLOADS_GET_LIST` once at boot | None |
| Source health probe ([source-health.service.ts:111–114](../../libs/portal/shared/data-access/src/lib/source-health.service.ts), #1592) | On-mount and manual checks. TTL 60 s, or 15 s when uncertain. The documented contract is demand-driven with no polling ([m3u-playlist-module.md](m3u-playlist-module.md#desktop-source-health)). | None | One Xtream `player_api.php` at boot (dashboard expiry check). No M3U probe on the dashboard. | Yes | Measured: 0 at idle | None |
| Auto-updater ([app-update.service.ts:291](../../apps/electron-backend/src/app/services/app-update.service.ts)) | One check at startup, packaged builds only, then only on request | None | — | Yes | Static; `APP_UPDATE:GET_STATUS` once at boot | None |
| Remote-control server ([http-server.ts:126](../../apps/electron-backend/src/app/server/http-server.ts)) | Listens only when the `remoteControl` setting is on (default off). The 2 s poll lives in the phone client and is served from memory. | None by default | — | Yes | Static; off in the profile | None |
| Connectivity guard ([host-connectivity-guard.ts:43](../../libs/shared/host-health/src/lib/host-connectivity-guard.ts)) | Compares against the clock lazily per request. The contract says no heartbeat ([host-connectivity-guard.md](host-connectivity-guard.md)). | None | — | Yes | Measured: 0 at idle | None |
| Playlist auto-refresh ([app.component.ts:261–294](../../apps/web/src/app/app.component.ts)) | One pass at startup for playlists with `autoRefresh` (default false) | Once | — | Yes | Static | None |
| PWA service worker ([app.config.ts:126–129](../../apps/web/src/app/app.config.ts), `ngsw-config.json`) | `registerWhenStable:30000`. It has asset groups only: no `dataGroups`, no periodic sync, and no `checkForUpdate` polling (`PwaService.checkUpdates` is never called). Disabled in Electron. | None | — | Yes | Static only; no PWA runtime capture was run | None |
### Conditional periodic work (not active on idle `/workspace`)
Found by reading the code. They run only on the listed route or state, so a
user who leaves that screen open pays them indefinitely, minimized included.
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
| --- | --- | --- | --- | --- | --- | --- |
| [mpv-session.service.ts:140–141](../../apps/electron-backend/src/app/events/mpv-session.service.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. |
| [vlc-session.service.ts:81–82](../../apps/electron-backend/src/app/events/vlc-session.service.ts) | External VLC position poll | 1.5 s delay, then 2 s | Up to three localhost TCP connections, then IPC | Yes while playing. The early-exit leak (orphaned 2 s poll) is **resolved by #1720**. | Static | Done (#1720). |
| [embedded-mpv-native.service.ts:1068](../../apps/electron-backend/src/app/services/embedded-mpv-native.service.ts) | Embedded MPV session snapshot poll | 500 ms while a session exists | Native snapshot, diff, and IPC only on change | Yes during playback. It is cleared when the last session closes. | Static | None |
| [embedded-mpv-session-controller.ts:197](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts) | Renderer-side bounds poll for a native-view embedded MPV session: compares `getBoundingClientRect()` with the last synced bounds | 500 ms while a native-view session is open (skipped for frame-copy) | One layout read outside the Angular zone; on drift a rAF and a `setEmbeddedMpvBounds` IPC | Yes during playback: a position-only layout shift would otherwise leave the native view misplaced | Static | Cover with the throttling thread: it kept its rate while minimized under `backgroundThrottling: false`. |
| [embedded-mpv-frame-pump.ts:212](../../apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts) (preload) | Frame-copy engine's frame pump: `pumpTick` re-arms itself with `requestAnimationFrame`, checks the shared-memory ring for a new frame, and uploads and draws it when one arrived | Every display frame while a frame-copy session is attached, paused included; stops only on detach. Opt-in (`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY`) | A ring read per frame; a texture upload and draw per new frame | Yes while the video is on screen. A paused or minimized session gains nothing from 60 Hz polling. | Static. Under `backgroundThrottling: false`, rAF kept firing in a minimized window: the E2E fixture's own rAF counter ran at 60 Hz there. | Cover with the throttling thread (rAF stops in a hidden window once throttling is on), and consider stopping the pump while paused. |
| [embedded-mpv-controls.adapter.ts:252](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts) (frame-copy) and [embedded-mpv-player.component.ts:587](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts) (native view) | Elapsed-time heartbeat of an active embedded-MPV recording; the two intervals are mutually exclusive by engine | 1 s while a recording is active | A signal write and a CD tick | Yes while the elapsed time is on screen. A minimized window gains nothing from it. | Static. Under `backgroundThrottling: false` it kept its rate while minimized. | Cover with the throttling thread (throttled while hidden once #1724 lands). |
| [embedded-mpv-reconnect.ts:296](../../apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts) | Reconnect backoff | 2 s to 30 s, at most 6 attempts | Native reload | Yes | Static | None |
| [channel-list-container.component.ts:426, :431](../../libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts) | M3U list: re-queries current programmes and metadata for **all** channels in the list, plus a progress tick | 60 s / 30 s while an M3U list is mounted | IPC and SQL that grow with channel count (not measured) | **Partly.** Only visible rows need refreshing. | Static | **Own thread.** Measure on a 10k-channel list, then limit to the viewport and pause on hidden. |
| [epg-refresh-coordinator.service.ts:82](../../libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.ts) | Xtream live: refreshes stale visible or tracked EPG entries | 60 s while live lists are registered | Xtream HTTP through main for stale entries only | Yes (already scoped) | Static | Pause on hidden once throttling allows it. |
| [stalker-watchdog.controller.ts:188](../../libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts) | Stalker `get_events` keep-alive | Portal `watchdog_timeout` (default 120 s) | A playlist row read and one portal HTTP request | Yes (portal protocol) | Static | None |
| [downloads.component.ts:223](../../libs/portal/downloads/feature/src/lib/downloads.component.ts), [recording-queue.component.ts:88](../../libs/portal/downloads/feature/src/lib/recording-queue.component.ts) | Recordings reload and elapsed-time tick | 15 s / 1 s while recordings are active and the page is open | `loadRecordings()` IPC and SQL / CD tick | **Partly.** Downloads are already push-based, so recordings could be too. | Static | **Own thread.** Push recording state like `onDownloadsUpdate`. |
| [controls-stream-stats.ts:42](../../libs/ui/playback/src/lib/player-controls/controls-stream-stats.ts) | Stream-info sampling | 1 s, only while the stream-info popover is open; `stop()` clears it when the popover closes | CD tick each | Yes (a closed popover costs nothing) | Static | None |
| 30 s EPG clocks in the Xtream, Stalker, EPG guide and unified live views | "Now" clocks for progress and the current programme | 30 s for as long as their route is open | CD tick each | Yes while visible | Static | Cover with the throttling thread (pause on hidden). |
| [empty-state.welcome-dashboard.scss:58](../../libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss) (`gridMove`, keyframes in `empty-state.component.scss:169`) | First-run dashboard with no playlists: the welcome state's `::before` grid drifts via an infinite `transform` animation | Continuous (60 s loop) while the empty dashboard is shown; the reduced-motion block does not stop the pseudo-element | Compositor and GPU frames only, no main-thread layout (not measured) | **Partly.** Decorative; a first-run screen left open keeps the GPU busy, and reduced motion should stop it. | Static | **Own thread** (small): stop the `::before` animation under `prefers-reduced-motion`, and consider pausing it while hidden (throttling stops compositor frames in a hidden window once #1724 lands). |
| [video-player.component.ts:790](../../libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts) `epgNowMs` | M3U playlist view: refreshes "now" for EPG state, unconditionally, separate from the channel-list timers above | 30 s while an M3U playlist route is open, with or without an active channel | A signal write and an app-wide CD tick | **Partly.** Needed while a programme is shown; not with no channel selected. | Static | Cover with the throttling thread (pause on hidden), and start it only while a channel with EPG is active. |
| [portal-empty-state.component.scss:25](../../libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss) `portal-empty-state-float` | Xtream, Stalker and unified live views with nothing selected or no results: the empty-state icon floats on an infinite `transform` animation | Continuous (2.8 s loop) while the empty state is shown | Compositor and GPU frames only (not measured) | **Partly.** Decorative; an empty portal view left open keeps the GPU busy. | Static | **Own thread** (small, with the welcome grid): stop decorative loops under reduced motion; #1724 stops their frames in a hidden window. |
Every infinite CSS animation in the renderer styles (`animation: … infinite`)
falls into one of three groups. **Loading indicators** (skeleton and list
shimmers, spinners, the refresh and download spinners, the EPG import spinner)
end with their loading state. **Playback and recording indicators** (live
pulses, the radio artwork and ring, the recording pulse and slide) run only
while something plays or records. **Decorative loops in idle states** are the
welcome grid and the portal empty-state icon listed above; they are the only
ones that keep running on a screen left alone.
## Related observation
[playback-keep-awake.service.ts](../../apps/web/src/app/services/playback-keep-awake.service.ts)
documents that "Visibility gates the lock in both modes: a minimized window
streaming audio in the background should not pin the display on". Under
`backgroundThrottling: false`, a minimized Electron window keeps
`visibilityState === 'visible'` and never fires `visibilitychange`, as
measured in this audit. So the Electron `powerSaveBlocker` likely stays held
for a minimized window playing video. Playback itself was not measured. Verify
this in the same thread as the first worst offender.
## Not covered
- Production (optimized, non-dev-mode) renderer costs. Timer, rAF, IPC, SQL,
network, layout and DOM-mutation counts carry over; the Angular
change-detection and template-update counts do not (production skips
`checkNoChanges`), and per-firing milliseconds are upper bounds.
- Windows and Linux. Throttling and occlusion behavior differ per platform.
- Idle during playback, and on routes other than the dashboard. The
conditional table above is from code reading only.
- A PWA runtime capture of the service worker.
@@ -481,3 +481,9 @@ reports slow imports of non-Latin playlists.
`node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`).
6. Validate a counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
## Idle work
The [idle work audit](idle-work-audit-2026-09.md) records what the app does
while the user does nothing, measured on the dashboard with the window visible
and minimized. Its **own thread** rows are candidate performance threads.