23 KiB
BitVid Performance & Efficiency Plan
Status: Stage 2 shipped; Stage 3 in progress — observer teardown shipped, probe cancellation next
Progress snapshot — 2026-07-26
The plan is being delivered as independent, test-gated slices on unstable.
Shipped
- Stage 0 foundation: the opt-in local performance harness is available; it remains disabled unless the loopback-only storage flag is set.
- Stage 1 complete: localhost relay-request monitoring is explicit opt-in.
- Stage 2 complete:
ExploreDataServiceis demand-created for For You and Explore, pauses outside active recommendation views, coalesces concurrent history/IDF refreshes, and no longer blocks cached For You rendering. - Stage 3 first slice: card, URL-health, and grid-health observers are disposed when feed markup is replaced or unmounted.
- Stage 7 first slice: recommendation refresh bursts are coalesced into one active pass plus one follow-up; Most Zapped ordering has regression coverage.
Remaining
- Capture and retain the complete Stage 0 baseline scenarios, including cache size, worker lifetime, probe counts, and browser CPU/RSS.
- Finish Stage 3 with owner-aware queue pruning and abort signals for URL and WebTorrent probes, then verify hidden-card reveal and fallback behavior.
- Finish Stage 7 with bounded, category-aware event-cache eviction and same-feed refresh coalescing without swallowing explicit refreshes.
- Implement Stage 5's service-worker update cadence and image-cache freshness policy after measuring warm-feed revalidation.
- Profile before changing Stage 4 (modal visuals) or Stage 6 (bundle/lazy loading); these remain unstarted and measurement-dependent.
Current release evidence
5f439d68: lazy recommendation indexes.687f8c43: lifecycle disposal, refresh coalescing, and Most Zapped ordering.bb6614e3: cache-first For You navigation rendering.- Targeted regression suites and production builds pass for each shipped slice;
the current remote tip is
origin/unstableatbb6614e3.
Goal
Reduce idle CPU, GPU, memory, relay traffic, and unnecessary network activity without making feeds stale or weakening BitVid's Nostr-native recommendation system.
BitVid must continue to ingest relevant notes, follows, zaps, watch events, and moderation signals promptly. The optimization is about scheduling and bounded work, not removing recommendation inputs or live feed updates.
Baseline findings
Local observations on 2026-07-26 while serving the production build:
| Component | Observed load | Conclusion |
|---|---|---|
Static serve dist process |
under 1% CPU, about 115 MB RSS | Not the heat source. |
| Browser main/renderer/GPU processes | renderer about 22% CPU, GPU about 7% CPU, renderer about 430 MB RSS | Primary area for profiling and improvement. |
| System memory | 24 GiB available; no swap in use | Not currently memory pressured. |
Code audit candidates:
ExploreDataServicestarts for every session and refreshes watch-history tags every minute and tag-IDF every five minutes, even outside Explore/For You.devReqMonitor.jsautomatically patches WebSocket sends on localhost and wakes every two seconds. This is development-only but makes local testing noisier and more expensive than production.- App startup registers/checks the service worker and explicitly calls
registration.update()on every load. - URL/torrent health checks can allocate media probes and WebTorrent tracker work; they must remain viewport-driven, bounded, and cancellable.
- The video-modal ambient background can perform recurring canvas draws while a video plays. It is a likely GPU hotspot and needs measurement before tuning.
- The image service-worker cache revalidates external images in the background; warm feeds can still cause many network/cache operations.
Deeper system audit
This table separates verified mechanics from hypotheses. It is deliberately not a list of features to remove: every item has a product boundary that must stay intact.
| System | Verified finding | Risk / opportunity | Planned boundary |
|---|---|---|---|
| Nostr subscriptions and feed loading | Feed loading deduplicates normal in-flight requests and active feeds need live relay updates. | Over-throttling would make Home, follows, zaps, and moderation stale. | Preserve subscriptions and event ingestion; coalesce only duplicate refresh/re-rank work. |
| For You / Explore indexes | ExploreDataService is constructed during bootstrap, clones the video set into a worker, refreshes history tags each minute and IDF each five minutes. Event, visibility, and timer paths can request the same broad work. |
Unused views pay recurrent CPU, worker-message, and allocation cost. | Keep lightweight dirty/version signals live; create and refresh the worker only for an active or imminent recommendation view, with one serialized refresh per index. |
| Event persistence and relay cache | IndexedDB writes are differential and idle-scheduled, but the payload is built from the complete allEvents and tombstone maps; restore likewise rehydrates the complete unexpired cache. TTL expiry is 10 minutes, with no explicit record/byte budget. |
Large relay sessions can raise startup, structured-clone/serialization, IndexedDB, and renderer-memory cost. | Measure actual record/byte growth first, then add an eviction budget that preserves active/latest, user-owned, watched, and moderation-relevant events. Never replace the cache with a smaller unscoped relay query. |
| URL liveness cards | The observer is viewport/prefetch-margin driven, and URL checks are deduplicated at controller level. Hidden-until-verified cards intentionally start an eager check so they can become visible. | A large hidden-card batch can still fill work queues; discarded cards cannot presently express cancellation through the generic observer. | Retain the reveal guarantee, but add queue cancellation, page/feed budgets, and primary-source-first fallback. |
| WebTorrent stream health | Torrent probes are globally queued (two at a time), cached, deduplicated, capped at four peer connections and destroy each probe torrent. A reusable probe client remains after first use. | The safeguards are good, but queue entries may outlive a discarded feed and a health badge can still pull WebTorrent into an otherwise hosted-video session. | Add cancellation/queue pruning and explicit health-probe telemetry before changing limits; evaluate lazy WebTorrent loading separately from playback correctness. |
| WebTorrent startup bundle | The WebTorrent facade imports the roughly 212 KB minified implementation at module evaluation time. | Browsers can download/parse a torrent stack before the viewer plays or probes a torrent. | Measure module transfer/parse on cold Home, then introduce a lazy implementation loader only if hosted playback and health-badge behavior remain correct. |
| Service worker and external images | Boot checks existing registrations and calls update(); the image strategy returns cache quickly but starts a network revalidation for each cached image request. |
Warm feeds can produce avoidable fetch/cache churn even when their images are already fresh. | Keep deploy reliability and bounded cache trimming; add a tested freshness policy rather than disabling updates or image caching. |
| Modal visuals and UI rendering | The ambient-video helper schedules animation-frame callbacks while active, although its current modal wiring needs trace validation. Feed rerenders replace card DOM and attach health observers again. | Canvas work may be a GPU cost; observer lifecycle needs a retained-node/queue profile before calling it a leak. | Instrument active animation callbacks and observed-card counts. Tune only if a playback trace shows material cost; add explicit observer teardown if retained cards are confirmed. |
| Development diagnostics | On localhost, devReqMonitor patches WebSocket sends and wakes every two seconds. |
It contaminates local CPU and relay measurements but is not a production heat source. | Make it opt-in so normal development reproduces production behavior. |
What is not a candidate for removal
- Nostr notes, follows, zaps, reactions, watch events, and moderation signals remain the raw material for feeds and recommendations.
- Active-feed subscriptions, source fallback, WebTorrent playback, and the stale-while-revalidate cache remain product capabilities.
- A timer or worker is changed only after a trace identifies it as active in a relevant scenario; no optimization is justified merely because code exists.
Design rules
- Nostr ingestion stays live. Keep subscriptions for active feeds and logged-in viewer context. New relevant events update local indexes promptly.
- Expensive work is demand-driven. A new event marks an index stale; it does not force a whole recommendation rebuild until the associated feed is active or about to become active.
- Inactive views do no recurring work. Hidden tabs, closed modals, and inactive feed views must pause timers, observers, and probe queues.
- Every background task has a bound. Cap concurrency, use TTL caches, cancel obsolete work, and destroy listeners/workers when their feature is no longer in use.
- No silent product degradation. Feed freshness, source fallback, WebTorrent playback, moderation, and user-visible recommendation inputs are acceptance criteria in every stage.
- Use cancellation, not just lower limits. A task belonging to a removed card, closed modal, inactive feed, or hidden document must be removable from the queue; lowering concurrency alone can leave obsolete work running.
- Measure both wall time and retained state. A faster task that retains a large worker snapshot, observer target, cache record, or probe client is not an efficiency win.
- Preserve discovery boundaries. A lifecycle or batching optimization must use the same scoped relay/source selection for historical fetch and live subscription. Never make a feature appear faster by querying a smaller, unrelated source set and silently omitting valid user data. Every affected feature needs a regression fixture containing data available only through its scoped historical source and data arriving live afterward.
Measurement harness (Stage 0) — foundation shipped; baseline capture remains
Create a local-only, opt-in performance harness. It must be disabled by default and absent from production behavior.
Implemented baseline surface: on localhost, 127.0.0.1, or ::1, set
localStorage.__bitvidPerformanceHarness__ = "1" and reload. The console API
window.__bitvidPerformance.snapshot() reports bounded long-task, resource,
navigation, heap (when supported), mark, and manually supplied gauge data.
mark, setGauge, and increment are available for later stage-specific
instrumentation. The harness does not initialize on a public host, even if the
storage key is set.
Measurements
- Long tasks via
PerformanceObserver. - JS heap, where the browser exposes it.
- Active workers, timers, observers, URL media probes, torrent probes, and relay subscription/request counts.
- Feed refresh duration, item count, and recomputation reason.
- Recommendation-index snapshots: input size, queue/coalescing count, worker lifetime, and stale-to-fresh latency.
- Event-cache record count, estimated serialized bytes, restore duration, IDB writes/deletes, and protected-versus-evicted record categories.
- Browser process CPU/RSS sampled externally during test scenarios.
Standard scenarios
- Cold load to Home; wait 60 seconds without interaction.
- Scroll through two feed pages, then stop.
- Open and close Profile; remain on Home.
- Open Explore and For You; receive a new relevant note, follow, zap, and moderation update.
- Open hosted playback, then close it.
- Open torrent-backed playback, then close it.
- Background the tab for two minutes and restore it.
- Load an inbox with both legacy NIP-04 and historical NIP-17 conversations, then receive a live NIP-17 message; verify all conversations are present.
Stage gate
Record baseline results before any optimization. The harness itself must have a test proving it does not initialize unless its explicit local debug flag is set.
Stage 1 — Make localhost relay monitoring explicit — complete
Change
Change js/devReqMonitor.js so it only installs when
localStorage.__bitvidReqMonitor__ === "1" on a loopback host. Do not
auto-enable merely because the host is localhost, and never permit the monitor
on a public host.
Why
It patches every WebSocket send, parses REQ frames, and has a two-second timer. It is useful during a relay-storm investigation but should not distort ordinary local performance testing.
Tests and acceptance
- With the flag off: no WebSocket patch and no monitoring timer.
- With the flag on: existing relay-storm warnings still identify REQ kinds.
- No production host can enable it without the explicit local flag.
- Compare idle local CPU/long-task baseline before and after.
Implemented with isRequestMonitorEnabled() / installRequestMonitor() and
regression coverage for disabled localhost, enabled loopback, and public-host
rejection. To use it during a targeted local relay investigation, set
localStorage.__bitvidReqMonitor__ = "1" and reload; remove the key or set it
to any other value for normal profiling.
Rollback
One isolated commit; restoring the previous activation condition restores the old diagnostic behavior.
Stage 2 — Lazy recommendation indexes
Implemented: ExploreDataService is no longer created during
ordinary bootstrap. Home/Profile sessions therefore create no recommendation
worker or recommendation intervals. Opening For You or Explore creates and
activates the service; events received while inactive mark it dirty and are
included in the activation refresh. Concurrent history/IDF signals share one
running rebuild and retain one forced follow-up pass. Focused lifecycle,
feed-coordinator, production-build, and browser navigation checks pass; manual
For You/Explore validation confirmed normal recommendations and responsive Home.
Change
Do not initialize ExploreDataService globally in application bootstrap.
Start it when Explore or For You is first opened, and stop/pause its intervals
when neither view is active or the document is hidden.
Keep Nostr ingestion active. Incoming videos, watch-history changes, follows, zaps, preference changes, and relevant moderation changes mark the associated index stale. Refresh the index immediately when its feed is visible, or just before rendering it.
Use a small lifecycle coordinator with these states: not-created, idle,
dirty, refreshing, and paused-hidden. A refresh request while refreshing
sets one follow-up dirty bit rather than starting another full worker message.
Snapshot only the minimal fields required by the worker and record snapshot
size in the local-only harness.
Why
The current worker receives cloned video/history data on a one-minute and five-minute cadence even when the viewer is using Home, Profile, or a modal.
Tests and acceptance
- Home/Profile-only session creates no Explore worker or recommendation intervals.
- Opening Explore or For You initializes the worker once and produces the same ranking inputs as today.
- A new relevant note, follow, watch event, zap, or moderation update is reflected on the next active-feed refresh.
- Backgrounding the tab pauses the intervals; restoring it performs one bounded catch-up refresh.
- Existing For You tiering, Trending, and recommendation tests remain green.
- Timer, visibility, and event triggers coalesce into at most one active and one follow-up index calculation per index.
Rollback
Feature-scoped lifecycle change behind a small coordinator boundary; restore eager initialization if feed freshness regresses.
Stage 3 — Bound source-health work to visible cards
Progress: first lifecycle-disposal slice shipped in 687f8c43; queue
ownership, cancellation, and abortable active probes remain.
Audit findings (2026-07-26)
createCardObserverretains anIntersectionObserverper grid container but has no disposal API.VideoListView.render()replaces card DOM without disconnecting the old observer, so detached cards can stay observed.gridHealthhas a globally bounded, priority-ordered torrent queue, but its queued jobs have no owner/cancellation handle. A discarded card can therefore still start a real tracker/WebRTC probe.- URL health uses an independent observer and video-probe wait queue with the same missing ownership boundary. Its fetch timeout aborts only on timeout, not when the card/grid disappears.
Implementation order: add disposable observer registrations and call them from grid replacement/unmount; associate queued jobs with card owners and prune jobs when their last owner disappears; finally pass an abort signal into active URL and torrent probes. Cache/in-flight deduplication stays shared so one visible card never cancels work still needed by another visible card.
Change
Keep the existing viewport/preload observer and current two-at-a-time torrent queue. Add an abort/cancellation token through URL probes and queue pruning for WebTorrent work when cards leave the feed or its container is discarded. Hidden cards that must be verified before reveal receive a small, observable per-feed budget rather than an unbounded eager batch. Preserve deduplication by event/source and separate positive/negative TTLs.
Do not probe every alternate mirror eagerly. Probe the primary source first and only test an alternate when the primary is unavailable or playback needs it.
Why
Media-element probes and WebTorrent tracker probes are useful for accurate source badges and fallback, but are among the most expensive background tasks.
Tests and acceptance
- Scrolling a large feed never exceeds the configured URL/torrent concurrency limit.
- Off-screen and removed cards do not start or retain probes.
- Cached results render badges without a second request.
- Dead URL still falls back to a working mirror or WebTorrent source.
- Closing a feed/modal tears down pending health work.
- Hidden-card verification is bounded and still reveals cards whose source proves healthy.
Rollback
Keep the existing probe implementation available behind the observer boundary until playback and badge tests pass on Chromium and Firefox.
Stage 4 — Reduce playback visual overhead
Change
First trace whether the video-modal ambient canvas is actually wired into the
current modal path, then profile it. If it appears in the hot path,
replace the free-running animation loop with requestVideoFrameCallback where
available, a capped fallback cadence, and automatic disablement for
prefers-reduced-motion or a low-power preference.
The effect must exist only while the modal video is actively playing and be fully torn down on pause, close, source change, and hidden document.
Tests and acceptance
- Hosted and torrent playback remain visually correct.
- Pause/close/source-change cancel all animation callbacks and observers.
- Background tabs make no canvas draws.
- Reduced-motion mode does not attach the visual effect.
- Compare renderer/GPU CPU while playing the same short clip before and after.
Rollback
Keep the visual treatment behind a feature/config gate until manual playback QA is complete.
Stage 5 — Service-worker and image-cache cadence
Change
- Keep service-worker registration, but avoid forcing a full update check on every application boot. Use normal browser update cadence or a bounded version/age policy.
- Add an image-cache freshness window so a cached external image is served immediately and only revalidated when stale or needed by a visible card.
Tests and acceptance
- A fresh deploy still activates normally without a hard refresh.
- Rollback deploy still works with hashed assets and normal reload.
- Cached thumbnails/avatars paint immediately.
- Warm feed loads do not revalidate every external image at once.
- Image cache remains bounded and recovers safely from quota failures.
Rollback
Keep cache format/version semantics intact; any regression can revert the freshness policy without deleting user-visible image caching.
Stage 6 — Bundle and feature activation review
Change
Start with the WebTorrent implementation, because it is currently imported by the facade at module evaluation time. Review other heavy feature modules only from measured cold-load transfer/parse evidence. Dynamically import at first use where safe: profile management, DMs, upload, torrent playback, and feature-specific Nostr helpers. Do not defer the active feed, authentication, or basic playback path in a way that creates visible delay.
Tests and acceptance
- Initial Home load has fewer transferred/parsed bytes.
- Opening each deferred UI initializes once and still works offline/static-hosted.
- No feature code is fetched when its flag is off.
- Build artifact, cache, E2E, and visual tests pass.
Stage 7 — Cache budget and feed-refresh coalescing
Progress: recommendation-index refresh coalescing shipped in 5f439d68;
the event-cache budget and full same-feed refresh coordinator remain.
Change
After Stage 0 records real cache growth, add explicit event-cache record and byte budgets plus category-aware eviction. Protect currently active feed items, the newest active version of an event, local/user-owned content, recent watch history pointers, and moderation state needed for the current viewer. Evict older, unreferenced relay data first. In the feed coordinator, coalesce same-feed refresh requests and record the initiating reason, without delaying an explicit user refresh.
Why
The cache is already incremental at write time, but its full-map snapshot and restore behavior can become expensive as a long-lived relay session grows. Coalescing protects against several valid live signals causing redundant render/rank passes together.
Tests and acceptance
- Seeded oversized caches restore only the protected/bounded record set and issue normal scoped relay fetches for missing items.
- Feed data, deletions, replacements, moderation decisions, and recent watch history produce the same visible result before and after eviction.
- A burst of equivalent refresh signals produces one running refresh plus, at most, one follow-up; an explicit refresh is never swallowed.
- IndexedDB and localStorage fallback behavior remain covered.
Rollback
Version cache metadata and keep the current restore format readable. Reverting the budget must not erase cached user data.
Release procedure
Each stage ships independently. The recommended order is Stage 0, Stage 1, Stage 2, Stage 3, Stage 7, Stage 5, then the measurement-dependent visual and bundle work (Stages 4 and 6):
- Capture baseline and define the expected improvement.
- Make one focused implementation commit.
- Run the stage's targeted tests plus relevant browser QA.
- Compare measurement results to the baseline.
- Run
npm run buildand the applicable design checks. - Promote only when no feed freshness, playback, moderation, or cache regression is observed.
Do not combine stages in one commit. A failed stage is reverted independently without discarding validated improvements from earlier stages.