Task 1.2: Append-only rendering for notifications page
- Extracted buildNotificationRow(item) helper from renderNotifications()
- Added prependNotificationRow(item) with sorted insertion by created_at
- addNotificationFromEvent uses prepend when DOM has children
Task 1.3: Fix notification document.click listener leak
- Removed per-row document.addEventListener('click') that leaked on every render
- Added single delegated click listener in main() using .notif-menu-panel class
- Menu panels still close when clicking outside .notif-right
Task 1.4: Cap posts[] and postIds with rolling eviction
- Added MAX_STORED_POSTS=500 constant to feed.html and post.html
- Added prunePostsArray() that sorts, slices, rebuilds postIds/renderedPostIds
- Called in upsertFeedPost/upsertPost with +50 batch threshold
- post.html skips pruning in event mode
Task 1.5: Cap notificationsById/unreadNotificationsById with periodic pruning
- Added MAX_STORED_NOTIFICATIONS=500, MAX_POST_IMAGE_CACHE=200
- Added pruneNotificationMaps() that prunes both Maps and LRU-caps postImageCache
- Called in addNotificationFromEvent, hydrateNotificationsFromCache/Relays
Updated plan checklist marking all Phase 1 tasks complete.
23 KiB
Long-Running Memory Bloat & Unresponsiveness Fix Plan
Problem Statement
When the client runs for extended periods, pages become bloated and unresponsive. The root causes fall into four categories:
- Unbounded in-memory growth — arrays/Maps/Sets that accumulate events without eviction
- Listener/event accumulation — DOM listeners and worker subscriptions that are never cleaned up
- Over-aggressive polling — 1-second intervals on every page, each triggering multiple worker round-trips
- Full re-renders —
innerHTML = ''+ rebuild on every incoming event, discarding DOM and re-attaching all listeners
The SharedWorker broadcasts every subscription event to every connected tab, and pages process all of them regardless of which subscription they originated from. Combined with full-DOM-rebuild rendering, this causes O(n) work on every event and linear memory growth over the session lifetime.
Scope
Pages affected: index.html, feed.html, post.html, notifications.html, relays.html
Worker affected: ndk-worker.js
Shared module affected: js/init-ndk.mjs
Architecture Overview
flowchart LR
subgraph Worker[ndk-worker.js SharedWorker]
sub[activeSubscriptions Map]
stats[relayActivityStats Map]
ports[connectedPorts array]
wallet[lightwalletTokenEvents Map]
broadcast[broadcast to all ports]
end
subgraph Tabs[Open Tabs - each gets ALL broadcasts]
index[index.html]
feed[feed.html]
post[post.html]
notif[notifications.html]
relays[relays.html]
end
sub -->|event/eose per sub| broadcast
stats -->|relayActivity per read/write| broadcast
broadcast -->|unfiltered| index
broadcast -->|unfiltered| feed
broadcast -->|unfiltered| post
broadcast -->|unfiltered| notif
broadcast -->|unfiltered| relays
feed -->|posts array grows forever| MemLeak[Memory Leak]
notif -->|notificationsById grows forever| MemLeak
notif -->|document.click leaked per row| ListenerLeak[Listener Leak]
feed -->|innerHTML rebuild per event| CPUSpike[CPU Spike]
relays -->|table rebuild every 5s| CPUSpike
Implementation Phases
Phase 1: P0 — Critical Rendering & Listener Leaks
Goal: Eliminate the two most severe causes of user-visible jank and memory growth.
1.1 Append-only rendering for feed and post pages
Files: www/feed.html, www/post.html
Current behavior: renderFeed() does divFeed.innerHTML = '' and rebuilds all cards on every incoming event.
Changes:
- Split
renderFeed()into two functions:renderFeed()— full rebuild, only called on initial load, "See More", sort changes, or mute removalprependPostCard(post)— creates a single card, inserts it at the top ofdivFeedif newer than the first visible post, and wires interactions for just that one card
- In the
ndkEventhandler, callprependPostCard(evt)instead ofrenderFeed()when a new kind-1 event arrives and the feed is already rendered - Cap the visible DOM: if
divFeed.children.length > displayCount + SEE_MORE_INCREMENT, remove the last child - Keep
posts[]sorted by inserting new events in the correct position (binary insert or simple unshift + periodic sort)
Acceptance criteria:
- New posts appear at the top without rebuilding existing cards
- Existing images/videos are not reloaded when a new post arrives
- "See More" still does a full re-render to show additional posts
1.2 Append-only rendering for notifications page
Files: www/notifications.html
Current behavior: renderNotifications() does divNotifications.innerHTML = '' and rebuilds all rows on every new notification.
Changes:
- Split into
renderNotifications()(full rebuild for filter changes / "View more") andprependNotificationRow(item)(single new row) - In
addNotificationFromEvent, callprependNotificationRowwhen the notifications list is already rendered and the new item passes the filter - Cap visible DOM to
visibleNotificationsCount + NOTIFICATIONS_PAGE_SIZE
Acceptance criteria:
- New notifications appear without rebuilding existing rows
- Filter toggles and "View more" still do full re-renders
1.3 Fix notification document.click listener leak
Files: www/notifications.html
Current behavior: renderNotifications() registers a document.addEventListener('click', ...) for every notification row, and these are never removed.
Changes:
- Remove the per-row
document.addEventListener('click', ...)entirely - Register a single document-level click listener in
main()that closes any open menu panel by checkingdocument.querySelector('.notif-menu-btn[aria-expanded="true"]')or similar - Alternatively, use a module-level
openMenuPanelvariable; the single listener checks if the click target is outsideopenMenuPaneland closes it
Acceptance criteria:
- Only one document click listener exists regardless of notification count
- Menu panels still close when clicking outside
1.4 Cap posts[] and postIds with rolling eviction
Files: www/feed.html, www/post.html
Current behavior: posts array and postIds Set grow without bound.
Changes:
- Add
const MAX_STORED_POSTS = 500constant - In
upsertFeedPost/upsertPost, after pushing a new event, ifposts.length > MAX_STORED_POSTS, sort bycreated_atdescending, slice toMAX_STORED_POSTS, and rebuildpostIdsfrom the remaining array - Also remove evicted post IDs from
renderedPostIds - In
post.htmlevent mode, skip this (single event display)
Acceptance criteria:
posts.lengthnever exceedsMAX_STORED_POSTS- Evicted posts are removed from
postIdsandrenderedPostIds - "See More" still works within the capped array
1.5 Cap notificationsById and unreadNotificationsById with periodic pruning
Files: www/notifications.html
Current behavior: Both Maps grow without bound; pruneNotificationsByCurrentSettings only runs on filter toggles.
Changes:
- Add
const MAX_STORED_NOTIFICATIONS = 500 - Add a
pruneNotificationMaps()function that:- Sorts
notificationsByIdentries bycreated_atdescending - Keeps only the top
MAX_STORED_NOTIFICATIONS - Deletes evicted entries from both
notificationsByIdandunreadNotificationsById - Also prunes
postImageCacheto an LRU of 200 entries
- Sorts
- Call
pruneNotificationMaps()at the end ofaddNotificationFromEventifnotificationsById.size > MAX_STORED_NOTIFICATIONS + 100 - Also call it after
hydrateNotificationsFromCacheandhydrateNotificationsFromRelays
Acceptance criteria:
notificationsById.sizenever exceedsMAX_STORED_NOTIFICATIONS + 100postImageCache.sizenever exceeds 200- Filter counts remain accurate after pruning
Phase 2: P1 — Subscription Hygiene & Polling Reduction
Goal: Stop the cross-tab event storm and reduce worker message queue saturation.
2.1 Filter ndkEvent by subId on the page side
Files: www/feed.html, www/post.html, www/notifications.html, www/js/init-ndk.mjs
Current behavior: The worker includes subId in event broadcasts (ndk-worker.js:1094), but pages' ndkEvent handlers process all events regardless of source subscription.
Changes:
- In
init-ndk.mjs, thendkEventCustomEvent already includes the fullmessage.data— verifysubIdis inevent.detail - Each page tracks its own active subscription IDs in a
Set:feed.html: track the kind-3 sub and the live feed subpost.html: track the kind-1 author/event subnotifications.html: track the notifications sub
- In the
ndkEventhandler, checkif (!mySubscriptionIds.has(event.detail.subId)) return;before processing
Acceptance criteria:
- Pages only process events from their own subscriptions
- Events from other tabs' subscriptions are ignored (no CPU work, no DOM updates)
2.2 Close old live subscriptions on follow-list change
Files: www/feed.html
Current behavior: ensureLiveFeedSubscription creates a new subscription when follows change but never closes the old one.
Changes:
- Add a module-level
let liveFeedSub = null;variable - In
ensureLiveFeedSubscription, before creating the new subscription:if (liveFeedSub?.close) { liveFeedSub.close(); } - Assign the return of
subscribe()toliveFeedSub - Also track the kind-3 subscription similarly and close it on logout
Acceptance criteria:
- Only one live feed subscription exists at a time in the worker
activeSubscriptionsMap in the worker doesn't accumulate stale feed subscriptions
2.3 Increase footer polling interval to 5 seconds (or event-driven)
Files: www/index.html, www/feed.html, www/notifications.html, www/relays.html
Current behavior: setInterval(UpdateFooter, 1000) on every page.
Changes:
- Change all 1-second footer intervals to 5 seconds (matching
post.htmlwhich already uses 5s) index.htmlline 850:setInterval(UpdateFooter, 5000)feed.htmlline 692:setInterval(updateFooter, 5000)notifications.htmlline 1390:setInterval(UpdateFooter, 5000)relays.htmlline 1794:setInterval(UpdateFooter, 5000)- Future enhancement (not in this phase): Make footer fully event-driven by reacting to
ndkRelaysandndkRelayActivityevents instead of polling
Acceptance criteria:
- No page polls the worker for footer data more than once per 5 seconds
- Footer still shows current relay status (5s staleness is acceptable)
2.4 Diff-update relay table instead of full rebuild
Files: www/relays.html
Current behavior: createRelayTable does divRelays.innerHTML = html every 5 seconds, destroying all rows, inputs, and listeners.
Changes:
- Split
createRelayTableinto:buildRelayTable(relays)— initial build (full innerHTML)updateRelayTableRows(relays)— in-place update of existing rows:- Update status icon, read/write counts, connection time text
- Update checkbox SVGs if relay type changed
- Add/remove rows if relay count changed
- Preserve the add-relay input value and focus
refreshRelayDatacallsupdateRelayTableRowsif the table already exists and the relay count matches; otherwise callsbuildRelayTable
Acceptance criteria:
- Relay table updates without losing input focus or re-attaching all listeners
- Add-relay input value is preserved across refreshes (already partially implemented)
2.5 Throttle relayActivity broadcasts in the worker
Files: www/ndk-worker.js
Current behavior: trackRelayRead and trackRelayWrite broadcast relayActivity on every single event read/write.
Changes:
- Add a
relayActivityBroadcastTimerand apendingRelayActivitySet of relay URLs that have had activity since the last broadcast - In
trackRelayRead/trackRelayWrite, add the relay URL topendingRelayActivityand schedule a 500ms batched broadcast if not already scheduled - The batched broadcast sends one
relayActivitymessage per pending relay with current stats, then clears the set - This reduces broadcast frequency from per-event to per-500ms-batch
Acceptance criteria:
relayActivitybroadcasts are batched to at most once per 500ms- Stats counts remain accurate (they're cumulative)
- Footer/sidenav relay indicators still update within 1s of activity
Phase 3: P2 — Worker Cleanup & Cache Eviction
Goal: Prevent unbounded growth in worker-side state and clean up dead connections.
3.1 Prune connectedPorts on tab close
Files: www/ndk-worker.js
Current behavior: connectedPorts accumulates dead ports; broadcast() iterates all of them.
Changes:
- In the
onconnecthandler (ndk-worker.js:7600), add:port.onclose = () => { connectedPorts = connectedPorts.filter(p => p !== port); if (activeSigningPort === port) activeSigningPort = null; };
Acceptance criteria:
connectedPortsonly contains live portsbroadcast()doesn't iterate dead ports
3.2 Evict inlineComposerInstances on re-render
Files: www/feed.html, www/post.html
Current behavior: inlineComposerInstances Map retains entries with detached hostEl after renderFeed() clears the container.
Changes:
- At the start of
renderFeed()(the full rebuild path), afterdivFeed.innerHTML = '':inlineComposerInstances.clear(); - Since the DOM is rebuilt, all composer host elements are gone; the Map entries are pure garbage
Acceptance criteria:
inlineComposerInstancesis empty after a full re-render- Composers can still be re-created on demand via
getOrCreateInlineComposer
3.3 Prune temporary relay Maps after broadcast publish
Files: www/ndk-worker.js
Current behavior: relayActivityStats, relayConnectAttemptStartedAt, relayLastDisconnectMeta accumulate entries for every temporary broadcast relay.
Changes:
- After the broadcast publish
phase: 'done'block (ndk-worker.js:6410), add a cleanup step:const userRelayUrls = new Set(Array.from(relayTypes.keys()).map(normalizeRelayUrl)); for (const url of relayActivityStats.keys()) { if (!userRelayUrls.has(url)) relayActivityStats.delete(url); } relayConnectAttemptStartedAt.clear(); // only used during active connect attempts // Keep relayLastDisconnectMeta for reconnect detection - Only prune
relayActivityStats— the disconnect meta is needed for reconnect subscription rebuilding
Acceptance criteria:
relayActivityStatsonly contains entries for the user's own relays after a broadcast completes- Reconnect detection still works (disconnect meta preserved)
3.4 Periodic reconciliation of lightwalletTokenEvents
Files: www/ndk-worker.js
Current behavior: lightwalletTokenEvents and lightwalletDeletedIds grow monotonically; deleted entries are only removed during publishDirectProofs.
Changes:
- Add a 5-minute interval timer
lightwalletReconcileTimerthat:- Iterates
lightwalletTokenEventsand removes any entry whose id is inlightwalletDeletedIds - Optionally trims
lightwalletDeletedIdsto the most recent 500 entries (keep recent ones to prevent re-processing)
- Iterates
- Clear this timer in
handleDisconnect
Acceptance criteria:
lightwalletTokenEventsdoesn't retain deleted token eventslightwalletDeletedIdsis bounded to ~500 entries
3.5 Clear health-check and prune intervals on disconnect
Files: www/ndk-worker.js
Current behavior: startRelayHealthCheck (ndk-worker.js:1262) and scheduleRelayEventPruning (ndk-worker.js:789) create intervals that are never cleared.
Changes:
- Store the health-check interval ID in a module-level variable
relayHealthCheckTimer - Store the prune interval ID in
relayEventsPruneTimer(already exists) - In
handleDisconnect()(ndk-worker.js:7350), add:if (relayHealthCheckTimer) { clearInterval(relayHealthCheckTimer); relayHealthCheckTimer = null; } if (relayEventsPruneTimer) { clearInterval(relayEventsPruneTimer); relayEventsPruneTimer = null; }
Acceptance criteria:
- No duplicate intervals after re-init
- Intervals are cleared on disconnect
Phase 4: P3 — Minor Leaks & Polish
Goal: Clean up remaining minor leaks and harden against edge cases.
4.1 Clear refreshTimes interval on notifications logout
Files: www/notifications.html
Current behavior: setInterval(refreshTimes, 30000) is never assigned to a variable and can't be cleared.
Changes:
- Assign to
let refreshTimesIntervalId = setInterval(refreshTimes, 30000) - Clear it in
Logout()
4.2 Remove duplicate window.addEventListener('message', ...) in relays.html
Files: www/relays.html
Current behavior: relays.html:1777 has a fallback message listener that duplicates the ndkRelayActivity handler.
Changes:
- Remove the
window.addEventListener('message', ...)block at lines 1777–1783 - The
ndkRelayActivityhandler at line 1762 already covers this
4.3 Add beforeunload cleanup for subscriptions on all pages
Files: www/feed.html, www/post.html, www/notifications.html
Current behavior: Pages don't close their subscriptions on beforeunload, leaving stale entries in the worker's activeSubscriptions Map until the worker detects the port close.
Changes:
- Add
window.addEventListener('beforeunload', () => { disconnect(); })(or close individual subscriptions) on each page - The
disconnect()function ininit-ndk.mjsalready sends adisconnectmessage and closes the port
Acceptance criteria:
- Worker's
activeSubscriptionsis cleaned up when a tab closes
Testing Strategy
Manual Testing
- Open
index.html,feed.html,post.html,notifications.html,relays.htmlin separate tabs - Leave them running for 30+ minutes with active subscriptions
- Check browser DevTools Memory tab:
- Take heap snapshots at 5min, 15min, 30min
- Verify heap growth is sublinear (not growing with event count)
- Check Performance tab:
- Record a 30s profile while events are flowing
- Verify no full
renderFeed/renderNotificationson every event
- Check that relay footer updates within 5s of connection changes
- Check that notification menu panels still open/close correctly
Automated Checks
- After each phase, run the existing test suite (
tests/broadcast-relay-test.shetc.) - Add a memory regression test if feasible (puppeteer heap snapshot comparison)
Risk Assessment
| Phase | Risk | Mitigation |
|---|---|---|
| Phase 1 (append-only) | Sort order bugs if new event is older than visible posts | Only prepend if newer than first visible; fall back to full render otherwise |
| Phase 1 (listener fix) | Menu panel might not close in all cases | Test thoroughly; use single delegated listener |
| Phase 2 (subId filter) | Might miss events if subId tracking is wrong | Log filtered vs processed events during testing |
| Phase 2 (polling change) | Footer might feel less responsive | 5s is still fast; relay status rarely changes in seconds |
| Phase 3 (worker cleanup) | Might prune stats too aggressively | Only prune temp relay stats, not user's own relays |
Files Modified
| File | Phases |
|---|---|
www/feed.html |
1, 2, 3, 4 |
www/post.html |
1, 2, 3, 4 |
www/notifications.html |
1, 2, 3, 4 |
www/relays.html |
2, 4 |
www/index.html |
2 |
www/ndk-worker.js |
2, 3 |
www/js/init-ndk.mjs |
2 |
Implementation Checklist
Phase 1: P0 — Critical Rendering & Listener Leaks
- 1.1 Append-only rendering for feed and post pages
- Split
renderFeed()into full rebuild +prependPostCard() - New posts prepend without rebuilding existing cards
- Cap visible DOM to
displayCount(trims last child on overflow) - "See More" still does full re-render
- Verified:
data-post-idattribute set byrenderPostItemfor trim logic - Reviewed: no errors found, fallback paths correct
- Split
- 1.2 Append-only rendering for notifications page
- Split
renderNotifications()into full rebuild +prependNotificationRow() - Extracted
buildNotificationRow(item)helper shared by both functions - New notifications prepend without rebuilding existing rows
- Filter toggles and "View more" still do full re-renders
- Split
- 1.3 Fix notification
document.clicklistener leak- Remove per-row
document.addEventListener('click', ...) - Register single delegated document-level click listener in
main() - Added
notif-menu-panelclass for panel discovery - Menu panels still close when clicking outside
- Remove per-row
- 1.4 Cap
posts[]andpostIdswith rolling eviction- Add
MAX_STORED_POSTS = 500constant - Evict oldest posts when array exceeds cap (+50 batch threshold)
- Rebuild
postIdsandrenderedPostIdsafter eviction - Skip pruning in post.html event mode
- Add
- 1.5 Cap
notificationsById/unreadNotificationsByIdwith periodic pruning- Add
MAX_STORED_NOTIFICATIONS = 500 - Add
pruneNotificationMaps()function - Call prune on overflow, after cache/relay hydration
- LRU-cap
postImageCacheto 200 entries
- Add
Phase 2: P1 — Subscription Hygiene & Polling Reduction
- 2.1 Filter
ndkEventbysubIdon the page side- Verify
subIdis inevent.detailfrom init-ndk.mjs - Track active subscription IDs per page
- Ignore events from foreign subscriptions
- Verify
- 2.2 Close old live subscriptions on follow-list change
- Store live feed subscription handle
- Close previous subscription before creating new one
- 2.3 Increase footer polling to 5 seconds
- index.html: 1s → 5s
- feed.html: 1s → 5s
- notifications.html: 1s → 5s
- relays.html: 1s → 5s
- 2.4 Diff-update relay table instead of full rebuild
- Split
createRelayTableinto build + update - In-place update of status icons, counts, checkboxes
- Preserve add-relay input focus across refreshes
- Split
- 2.5 Throttle
relayActivitybroadcasts in the worker- Add batched broadcast with 500ms timer
- Coalesce per-event broadcasts into per-batch
- Stats counts remain accurate
Phase 3: P2 — Worker Cleanup & Cache Eviction
- 3.1 Prune
connectedPortson tab close- Add
port.onclosehandler inonconnect - Clear
activeSigningPortif it was the closed port
- Add
- 3.2 Evict
inlineComposerInstanceson re-render- Clear Map after
innerHTML = ''in full rebuild path
- Clear Map after
- 3.3 Prune temporary relay Maps after broadcast publish
- Remove non-user relay entries from
relayActivityStats - Clear
relayConnectAttemptStartedAt - Preserve
relayLastDisconnectMetafor reconnect detection
- Remove non-user relay entries from
- 3.4 Periodic reconciliation of
lightwalletTokenEvents- Add 5-minute interval timer
- Remove deleted entries from
lightwalletTokenEvents - Trim
lightwalletDeletedIdsto 500 - Clear timer in
handleDisconnect
- 3.5 Clear health-check and prune intervals on disconnect
- Store health-check interval ID
- Clear both intervals in
handleDisconnect
Phase 4: P3 — Minor Leaks & Polish
- 4.1 Clear
refreshTimesinterval on notifications logout - 4.2 Remove duplicate
messagelistener in relays.html - 4.3 Add
beforeunloadsubscription cleanup on all pages