Skip to content

perf(runtime): skip unread BroadcastChannel deliveries and let idle channels be collected - #2086

Draft
edusperoni wants to merge 1 commit into
mainfrom
perf/broadcast-channel-idle-channels
Draft

edusperoni wants to merge 1 commit into
mainfrom
perf/broadcast-channel-idle-channels

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Android counterpart of NativeScript/ios#543, kept in sync with it.

What

Every open BroadcastChannel deserialized every message posted on its name and dispatched two MessageEvents, even with no listener. The first went on the hidden port, and a relay listener re-wrapped it onto the channel. A channel was also held strongly until close(). Fire-and-forget senders such as new BroadcastChannel(n).postMessage(x) therefore stayed as group members forever, and each paid that cost on every later post.

Changes

  • Delivery checks for listeners on the channel. The channel reports its own message/messageerror listener count through the kListenerChanged hook, the same one MessagePort uses. While it has a listener, native holds the channel as the port's delivery target and dispatches one MessageEvent directly on it. Without a listener, a drained message is discarded unread: no deserialization, no event, no microtask checkpoint.
  • The check runs at delivery, not at post. A message posted before onmessage is attached in the same turn is still delivered, as in browsers.
  • Lifetime follows HTML's GC rule. That delivery target is the only strong reference to the channel. An open channel with no listener can be collected, which is also what Chromium, WebKit and Gecko do, and a FinalizationRegistry closes its port so it leaves the group.
  • Destination order. Group members are kept in join order, so destinations on one isolate are served in channel creation order as HTML specifies, rather than in pointer order.
  • Unstarted ports. A MessagePort that hasn't been started is no longer woken for every incoming burst; only its close sentinel wakes it.

The native side is the same change as iOS, ported onto Android's Messaging.cpp. broadcast-channel.js is now byte-identical across both runtimes.

Numbers

Measured on iOS (Release, simulator) with the same native design. I didn't benchmark Android separately.

  • 10 silent peers, 50-item payload: 132.5 µs → 7.9 µs per message, which is about the cost of postMessage itself.
  • 500 fire-and-forget senders: 294 µs → 9 µs per send.

Deliberate differences

  • From Node: Node keeps an unclosed channel alive until close()/unref(). This runtime now follows the browsers and HTML instead.
  • Proxy-wrapped channels: a listener added through a Proxy around a channel reaches the channel's listener list, but the hook sees the proxy and cannot reach the port. Messages are then discarded.

Out of scope

  • Ordering with more than one message in flight: one drain per port serves its whole backlog. On one isolate, two channels receiving two messages run A1, A2, B1, B2 instead of HTML's A1, B1, A2, B2. This predates the change.

Tests

test-app/app/src/main/assets/app/tests/testMessaging.js gains the same 10 BroadcastChannel specs as iOS:

  • same-turn attach
  • discard while unheard
  • stop/resume with listener changes
  • a single event dispatched at the channel
  • creation order
  • collection with no listener, after the last listener is removed, and after close
  • a messageerror-only listener keeps the channel alive
  • an unreferenced channel with a listener keeps receiving

Full suite on main + this commit, on a Pixel 9 Pro API 35 arm64 emulator: 1478 specs, 0 failures, 4 skipped. The skips are unrelated to messaging.

@coderabbitai

coderabbitai Bot commented Nov 5, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

…hannels be collected

Every open BroadcastChannel deserialized every message posted on its name
and dispatched two MessageEvents (one on the hidden port, one re-wrapped
by a relay onto the channel), whether or not anything listened. A
channel was also held until close(), so fire-and-forget senders
accumulated as group members that each paid that cost on every later
post.

- The channel reports its own 'message'/'messageerror' listener count
  through the kListenerChanged hook. While it has one, native holds the
  channel strongly as the port's delivery target and dispatches a single
  MessageEvent on it; without one, a drained message is discarded
  unread. The decision is made per message at delivery, so a listener
  attached in the same turn as the post still receives it.
- That strong reference is the only one, matching HTML's GC rule: an
  open channel with no listener is collectable, and a FinalizationRegistry
  closes its port so it leaves the group.
- Group members are kept in join order, so destinations on one isolate
  are served in channel creation order as HTML specifies, rather than
  pointer order.
- A port that has not been started is no longer woken for each incoming
  burst; only its close sentinel wakes it.

Mirrors NativeScript/ios#543.
@edusperoni
edusperoni force-pushed the perf/broadcast-channel-idle-channels branch from 8f59fc9 to 71719d1 Compare November 5, 2026 20:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant