From 7e3ba271a3d990bd7ddd4a7460acf2ad8ac83ec6 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Mon, 27 Apr 2026 16:16:44 -0700 Subject: [PATCH 01/12] Proposal: Asynchronous Listener Registration --- proposals/async-listener-registration.md | 237 +++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 proposals/async-listener-registration.md diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md new file mode 100644 index 000000000..ca3d10744 --- /dev/null +++ b/proposals/async-listener-registration.md @@ -0,0 +1,237 @@ +# Proposal: Asynchronous Listener Registration + +**Summary** + +An API proposal to allow extensions to explicitly manage their service worker initialization phase via a manifest opt-in field and a `markInitializationComplete()` API, enabling the asynchronous registration of event listeners. + +**Document Metadata** + +**Author:** andreaorru@chromium.org + +**Sponsoring Browser:** Google Chrome + +**Contributors:** rdevlin.cronin@chromium.org + +**Created:** 2026-04-22 + +**Related Issues:** N/A + +## Motivation + +### Objective + +Currently, the extension platform requires event listeners to be registered synchronously during the execution of a service worker script. The browser process manages event routing and relies on the renderer process to report which events an extension is listening to immediately after the initial script evaluation. + +If an extension registers a listener asynchronously (e.g., inside a `setTimeout`, after a `fetch()`, or after reading from `browser.storage`), the initial script evaluation finishes before the listener is attached. Consequently, when the relevant event fires later, the service worker wakes up, executes its top-level script synchronously, and the browser dispatches the event to all registered listeners (of which there may be none). Thus, the service worker wakes up but receives no event. + +This proposal introduces a mechanism to explicitly delay the finalization of the "initialization phase" of the worker and formalizes how listener state is managed and persisted across service worker restarts. + +#### Use Cases + +* **Conditional event registration:** An extension may need to read user preferences from local storage to determine whether it should register listeners for high-traffic events (e.g., `tabs.onUpdated` or `webRequest`). +* **Dynamic configuration:** An extension might need to fetch a configuration file to decide which listener options to set up (e.g. `webRequest` URL filters). + +### Known Consumers + +Extensions which require dynamic or conditionally registered event listeners based on asynchronous state, without resorting to unconditionally waking up the service worker for events they may not care about. + +## Specification + +### Schema + +#### Manifest + +```json +{ + "background": { + "service_worker": "background.js", + "async_initialization": true + } +} +``` + +#### Runtime API + +```typescript +namespace runtime { + /** + * Signals to the browser that asynchronous initialization is complete. + * Transitions the service worker out of the initialization phase, replaces + * the previous run's persisted listeners with the newly registered ones, + * and flushes the event queue. + */ + export function markInitializationComplete(): void; +} +``` + +### Behavior + +#### Initialization Lifecycle + +* **Manifest opt-in:** Extensions declare `"async_initialization": true` nested inside the `"background"` key in their `manifest.json`. +* **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the service worker's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). +* **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. +* **Listener registration:** Developers perform their asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. +* **State commit & completion API:** The extension must call `browser.runtime.markInitializationComplete()` once its async setup is complete. At that moment: + 1. The old persisted routing state is **thrown away**. + 2. The new listeners currently registered are committed to the browser as the new persisted routing state for future wake-ups. +* **Event flush:** Once `markInitializationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. + +#### Example Flow + +To illustrate how these lifecycle changes resolve the timing issues of asynchronous setup, consider an extension that conditionally tracks tab updates based on an asynchronous storage read. + +```javascript +browser.storage.local.get("shouldListen").then((settings) => { + // Asynchronously evaluate state. + if (settings?.shouldListen) { + // Conditionally register the listener based on the async result. + browser.tabs.onUpdated.addListener(handleTabUpdate); + } + // Signal the end of the initialization phase. + browser.runtime.markInitializationComplete(); +}); +``` + +Assume that during a previous run, `settings.shouldListen` was `true`, the listener was registered, and the browser persisted this routing state. The service worker has since been terminated. + +When a new `tabs.onUpdated` event fires, the browser checks the persisted state and wakes the service worker. Because the manifest includes `"async_initialization": true`, the renderer process queues the waking event rather than attempting to dispatch it immediately. + +From here, execution resolves into one of two cases. + +##### Case 1: The listener is registered + +If the storage promise resolves and `settings.shouldListen` evaluates to `true`: +1. The extension executes the `addListener()` call. +2. The extension calls `browser.runtime.markInitializationComplete()`. +3. The browser commits the routing state (maintaining the `tabs.onUpdated` registration for future wake-ups). The renderer flushes its holding queue, and the queued `tabs.onUpdated` event is safely dispatched to the newly attached `handleTabUpdate` callback. No events were missed. + +##### Case 2: The listener is not registered + +If the storage promise resolves but `settings.shouldListen` evaluates to `false`: +1. The extension skips the `addListener()` call. +2. The extension calls `browser.runtime.markInitializationComplete()`. +3. The browser commits the routing state. Because the listener was omitted during this run, the browser overwrites and **clears** the old persisted routing state. The renderer flushes the queued event, which is harmlessly dropped since no JavaScript callback is attached. +4. Because the routing state is now cleared, future `tabs.onUpdated` events will no longer wake up the service worker. This implicitly cleans up the routing state without the developer needing to explicitly call `removeListener()` (see Workaround B) or accept unnecessary wake-ups on future events (see Workaround A). + +#### Edge Cases + +* **Initialization failure fallback:** If the initialization phase fails (e.g., the worker crashes or hits a timeout before the completion API is called), the browser will abort the state commit. It will keep the previous persisted routing state to prevent leaving the extension in a broken state. +* **Late registration:** It is permissible to register new listeners after the `markInitializationComplete()` API call. If an extension does so, developers must understand the following lifecycle: + * The browser will persist these "late" listeners across service worker terminations to successfully trigger a future wake-up. + * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markInitializationComplete()` entirely replaces the routing state from the previous run. + * Therefore, any late-registered listeners from a previous run will be removed from the registered listeners during the next run's state commit unless the extension explicitly re-registers them before calling the completion API. + +#### Performance Considerations + +Because MV3 service workers are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markInitializationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. + +### New Permissions + +| Permission Added | Suggested Warning | +| ---------------- | ----------------- | +| N/A | N/A | + +This API modifies the initialization lifecycle of the extension's service worker but does not grant any new access to user data or privileged browser features. Therefore, no new permissions are required. + +### Manifest File Changes + +Add an optional (default false) `async_initialization` field inside the `background` key. + +```json +{ + "background": { + "service_worker": "background.js", + "async_initialization": true + } +} +``` + +## Security and Privacy + +### Exposed Sensitive Data + +This API does not expose any sensitive data or personally identifiable information. + +### Abuse Mitigations + +* **Indefinite hangs:** A malicious or poorly coded extension could fail to call `markInitializationComplete()` in an attempt to consume memory by forcing the browser to queue a massive number of events. This is mitigated by queuing the events in the renderer process, scoping the impact to the renderer only. The browser can also enforce an initialization timeout, falling back to the previous run's routing state if it expires. +* **Queue limits:** Implementations could enforce an upper bound on the event queue size, dropping the oldest events if the limit is exceeded during the wait phase to prevent unbounded memory growth. Again, because the queuing happens renderer-side, in the worst case scenario a misbehaving renderer can be killed. + +## Alternatives + +### Existing Workarounds + +Currently, developers must work around the lack of async initialization using patterns that are suboptimal. + +#### Workaround A: Discarding events inside the listener callback + +Developers register the listener synchronously, load their configuration asynchronously, and check the state *inside* the callback. + +```javascript +let config = null; +browser.storage.local.get("config").then(res => config = res); + +browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (!config || !config.enableTabTracking) return; // Discard event. + // ... actual logic ... +}); +``` + +This incurs significant performance overhead. It forces the browser to spin up the service worker environment and dispatch the event every single time it fires, only for the extension to immediately discard it. + +#### Workaround B: The `removeListener` pattern + +Developers register the listener unconditionally on startup. Once the asynchronous state resolves, if the feature is disabled, they explicitly unregister the listener. + +```javascript +const handleTabUpdate = (tabId, changeInfo, tab) => { /* logic */ }; +browser.tabs.onUpdated.addListener(handleTabUpdate); + +browser.storage.local.get("config").then(config => { + if (!config.enableTabTracking) { + browser.tabs.onUpdated.removeListener(handleTabUpdate); + } +}); +``` + +While this prevents *future* unnecessary wake-ups (as the browser persists the updated routing table), it still incurs an initial wake-up penalty. It also requires excessive boilerplate to manually map, re-add, and remove listener references as state changes, complicating extension logic. The proposed API avoids both the initial wake-up penalty and the complex teardown boilerplate, as old listeners are automatically replaced upon completion of initialization. + +#### Workaround C: Listener substitution hack for dynamic filters + +When an extension relies on dynamically configured event filters (e.g., user-defined URLs for `webNavigation`), it cannot register the filtered listener synchronously on a cold start because the filters haven't been loaded from storage yet. To avoid missing the event that triggered the service worker's wake-up, the extension must register a broad, unfiltered listener to act as a temporary catch-all, and later substitute it. + +```javascript +// 1. Synchronously register an unfiltered catch-all to receive the waking event. +const catchAllListener = (details) => { /* temporarily handle or queue event */ }; +browser.webNavigation.onBeforeNavigate.addListener(catchAllListener); + +// 2. Asynchronously load the dynamic filters. +browser.storage.local.get("userFilters").then(filters => { + // 3. Register the actual filtered listener so the browser's persistent + // event router knows to wake the SW for these URLs in the future. + browser.webNavigation.onBeforeNavigate.addListener( + (details) => { /* handle actual event */ }, + { url: filters } + ); + + // 4. Remove the temporary catch-all listener. + browser.webNavigation.onBeforeNavigate.removeListener(catchAllListener); +}); +``` + +This pattern (really just a hack) is very convoluted and brittle: it forces the extension to temporarily over-listen, catching potentially irrelevant events while the async task is pending. Notably, the Chromium codebase contains tests specifically designed to ensure this workaround functions (see https://crrev.com/c/7604661), with comments explicitly noting that this is required *"until we have a better way for extensions to register listeners asynchronously at startup."* + +### Open Web API + +Service workers on the open web handle initialization via the `install` and `activate` lifecycle events, utilizing `event.waitUntil()` to extend the phase for asynchronous tasks. We considered introducing an extension-specific lifecycle event (e.g., `browser.runtime.onRegisterListeners` with a `waitUntil()` method), but explicitly using a manifest opt-in paired with a completion call is simpler and avoids introducing entirely new lifecycle events just for extensions. Additionally, `onRegisterListeners` would need to be synchronously registered at the top level, which could be confusing. + +## Implementation Notes + +* The `async_initialization` manifest key allows the browser to know before spinning up the service worker that it should start queuing events *in the renderer*. +* The browser's event router must compute the union of the old persisted listener configurations and any newly evaluated configurations during startup to determine which events to forward to the initializing renderer. +* Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markInitializationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. + +## Future Work + +N/A From 7635a7d64f6142db7cac2251982189b954a16444 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Mon, 27 Apr 2026 16:54:20 -0700 Subject: [PATCH 02/12] Update usernames and creation date --- proposals/async-listener-registration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index ca3d10744..1b033efc1 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -6,13 +6,13 @@ An API proposal to allow extensions to explicitly manage their service worker in **Document Metadata** -**Author:** andreaorru@chromium.org +**Author:** @AndreaOrru **Sponsoring Browser:** Google Chrome -**Contributors:** rdevlin.cronin@chromium.org +**Contributors:** @rdcronin -**Created:** 2026-04-22 +**Created:** 2026-04-27 **Related Issues:** N/A From 8f5bacc11908c8f3088f7942e7ac43d77bcda8cd Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:32:04 -0700 Subject: [PATCH 03/12] Update summary (manage => delay dispatch) Co-authored-by: Rob Wu --- proposals/async-listener-registration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index 1b033efc1..93403ef5e 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -2,7 +2,7 @@ **Summary** -An API proposal to allow extensions to explicitly manage their service worker initialization phase via a manifest opt-in field and a `markInitializationComplete()` API, enabling the asynchronous registration of event listeners. +An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their service worker during its initialization phase via a manifest opt-in field and a `markInitializationComplete()` API, enabling the asynchronous registration of event listeners. **Document Metadata** From 6f318e6a3f8e5bb25f5c080883849f746b9b10eb Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:35:02 -0700 Subject: [PATCH 04/12] Clarify the method is only available to the background script context Co-authored-by: Rob Wu --- proposals/async-listener-registration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index 93403ef5e..57e0cd890 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -59,6 +59,8 @@ namespace runtime { * Transitions the service worker out of the initialization phase, replaces * the previous run's persisted listeners with the newly registered ones, * and flushes the event queue. + * + * This method is only available to the background script context. */ export function markInitializationComplete(): void; } From b2a60156202141fec5ce3085d66d997badea5d68 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:36:06 -0700 Subject: [PATCH 05/12] Potentially async Co-authored-by: Rob Wu --- proposals/async-listener-registration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index 57e0cd890..a601a83bd 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -74,7 +74,7 @@ namespace runtime { * **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the service worker's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). * **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. * **Listener registration:** Developers perform their asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. -* **State commit & completion API:** The extension must call `browser.runtime.markInitializationComplete()` once its async setup is complete. At that moment: +* **State commit & completion API:** The extension must call `browser.runtime.markInitializationComplete()` once its potentially async setup is complete. At that moment: 1. The old persisted routing state is **thrown away**. 2. The new listeners currently registered are committed to the browser as the new persisted routing state for future wake-ups. * **Event flush:** Once `markInitializationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. From a58b8b2dbc237038201f66e2b96ec3def8a81488 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:36:24 -0700 Subject: [PATCH 06/12] Potentially async 2 Co-authored-by: Rob Wu --- proposals/async-listener-registration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index a601a83bd..f7d29a13f 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -73,7 +73,7 @@ namespace runtime { * **Manifest opt-in:** Extensions declare `"async_initialization": true` nested inside the `"background"` key in their `manifest.json`. * **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the service worker's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). * **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. -* **Listener registration:** Developers perform their asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. +* **Listener registration:** Developers perform their potentially asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. * **State commit & completion API:** The extension must call `browser.runtime.markInitializationComplete()` once its potentially async setup is complete. At that moment: 1. The old persisted routing state is **thrown away**. 2. The new listeners currently registered are committed to the browser as the new persisted routing state for future wake-ups. From c6bbf7b3ee78e5053d85712f9e06d920266f4c85 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:39:05 -0700 Subject: [PATCH 07/12] Atomic startup completion signal --- proposals/async-listener-registration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index f7d29a13f..af1ee8b81 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -30,6 +30,7 @@ This proposal introduces a mechanism to explicitly delay the finalization of the * **Conditional event registration:** An extension may need to read user preferences from local storage to determine whether it should register listeners for high-traffic events (e.g., `tabs.onUpdated` or `webRequest`). * **Dynamic configuration:** An extension might need to fetch a configuration file to decide which listener options to set up (e.g. `webRequest` URL filters). +* **Atomic startup completion signal**: An extension that partially failed to register its listeners at its first run can have another chance at the next startup, without being in an inconsistent state (e.g. https://bugzilla.mozilla.org/show_bug.cgi?id=1822735). ### Known Consumers From 5ffc828d69f3e9676ac8d113464814264d4b1b77 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:42:03 -0700 Subject: [PATCH 08/12] Clarify behavior when method is called during sync startup --- proposals/async-listener-registration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index af1ee8b81..60e07283a 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -234,6 +234,7 @@ Service workers on the open web handle initialization via the `install` and `act * The `async_initialization` manifest key allows the browser to know before spinning up the service worker that it should start queuing events *in the renderer*. * The browser's event router must compute the union of the old persisted listener configurations and any newly evaluated configurations during startup to determine which events to forward to the initializing renderer. * Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markInitializationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. +* When the initialization completion is signaled while the background script is still starting/executing, events are not queued and the usual event dispatch happens as soon as possible. ## Future Work From b6f95f73069b008836d97a01097dee70266c9b86 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:46:56 -0700 Subject: [PATCH 09/12] service worker => background context --- proposals/async-listener-registration.md | 32 ++++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index 60e07283a..26eca4cbf 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -2,7 +2,7 @@ **Summary** -An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their service worker during its initialization phase via a manifest opt-in field and a `markInitializationComplete()` API, enabling the asynchronous registration of event listeners. +An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their background context during its initialization phase via a manifest opt-in field and a `markInitializationComplete()` API, enabling the asynchronous registration of event listeners. **Document Metadata** @@ -20,11 +20,11 @@ An API proposal to allow extensions to explicitly delay the dispatch of extensio ### Objective -Currently, the extension platform requires event listeners to be registered synchronously during the execution of a service worker script. The browser process manages event routing and relies on the renderer process to report which events an extension is listening to immediately after the initial script evaluation. +Currently, the extension platform requires event listeners to be registered synchronously during the execution of a background context script. The browser process manages event routing and relies on the renderer process to report which events an extension is listening to immediately after the initial script evaluation. -If an extension registers a listener asynchronously (e.g., inside a `setTimeout`, after a `fetch()`, or after reading from `browser.storage`), the initial script evaluation finishes before the listener is attached. Consequently, when the relevant event fires later, the service worker wakes up, executes its top-level script synchronously, and the browser dispatches the event to all registered listeners (of which there may be none). Thus, the service worker wakes up but receives no event. +If an extension registers a listener asynchronously (e.g., inside a `setTimeout`, after a `fetch()`, or after reading from `browser.storage`), the initial script evaluation finishes before the listener is attached. Consequently, when the relevant event fires later, the background context wakes up, executes its top-level script synchronously, and the browser dispatches the event to all registered listeners (of which there may be none). Thus, the background context wakes up but receives no event. -This proposal introduces a mechanism to explicitly delay the finalization of the "initialization phase" of the worker and formalizes how listener state is managed and persisted across service worker restarts. +This proposal introduces a mechanism to explicitly delay the finalization of the "initialization phase" of the worker and formalizes how listener state is managed and persisted across background context restarts. #### Use Cases @@ -34,7 +34,7 @@ This proposal introduces a mechanism to explicitly delay the finalization of the ### Known Consumers -Extensions which require dynamic or conditionally registered event listeners based on asynchronous state, without resorting to unconditionally waking up the service worker for events they may not care about. +Extensions which require dynamic or conditionally registered event listeners based on asynchronous state, without resorting to unconditionally waking up the background context for events they may not care about. ## Specification @@ -57,7 +57,7 @@ Extensions which require dynamic or conditionally registered event listeners bas namespace runtime { /** * Signals to the browser that asynchronous initialization is complete. - * Transitions the service worker out of the initialization phase, replaces + * Transitions the background context out of the initialization phase, replaces * the previous run's persisted listeners with the newly registered ones, * and flushes the event queue. * @@ -72,7 +72,7 @@ namespace runtime { #### Initialization Lifecycle * **Manifest opt-in:** Extensions declare `"async_initialization": true` nested inside the `"background"` key in their `manifest.json`. -* **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the service worker's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). +* **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the background context's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). * **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. * **Listener registration:** Developers perform their potentially asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. * **State commit & completion API:** The extension must call `browser.runtime.markInitializationComplete()` once its potentially async setup is complete. At that moment: @@ -96,9 +96,9 @@ browser.storage.local.get("shouldListen").then((settings) => { }); ``` -Assume that during a previous run, `settings.shouldListen` was `true`, the listener was registered, and the browser persisted this routing state. The service worker has since been terminated. +Assume that during a previous run, `settings.shouldListen` was `true`, the listener was registered, and the browser persisted this routing state. The background context has since been terminated. -When a new `tabs.onUpdated` event fires, the browser checks the persisted state and wakes the service worker. Because the manifest includes `"async_initialization": true`, the renderer process queues the waking event rather than attempting to dispatch it immediately. +When a new `tabs.onUpdated` event fires, the browser checks the persisted state and wakes the background context. Because the manifest includes `"async_initialization": true`, the renderer process queues the waking event rather than attempting to dispatch it immediately. From here, execution resolves into one of two cases. @@ -115,19 +115,19 @@ If the storage promise resolves but `settings.shouldListen` evaluates to `false` 1. The extension skips the `addListener()` call. 2. The extension calls `browser.runtime.markInitializationComplete()`. 3. The browser commits the routing state. Because the listener was omitted during this run, the browser overwrites and **clears** the old persisted routing state. The renderer flushes the queued event, which is harmlessly dropped since no JavaScript callback is attached. -4. Because the routing state is now cleared, future `tabs.onUpdated` events will no longer wake up the service worker. This implicitly cleans up the routing state without the developer needing to explicitly call `removeListener()` (see Workaround B) or accept unnecessary wake-ups on future events (see Workaround A). +4. Because the routing state is now cleared, future `tabs.onUpdated` events will no longer wake up the background context. This implicitly cleans up the routing state without the developer needing to explicitly call `removeListener()` (see Workaround B) or accept unnecessary wake-ups on future events (see Workaround A). #### Edge Cases * **Initialization failure fallback:** If the initialization phase fails (e.g., the worker crashes or hits a timeout before the completion API is called), the browser will abort the state commit. It will keep the previous persisted routing state to prevent leaving the extension in a broken state. * **Late registration:** It is permissible to register new listeners after the `markInitializationComplete()` API call. If an extension does so, developers must understand the following lifecycle: - * The browser will persist these "late" listeners across service worker terminations to successfully trigger a future wake-up. + * The browser will persist these "late" listeners across background context terminations to successfully trigger a future wake-up. * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markInitializationComplete()` entirely replaces the routing state from the previous run. * Therefore, any late-registered listeners from a previous run will be removed from the registered listeners during the next run's state commit unless the extension explicitly re-registers them before calling the completion API. #### Performance Considerations -Because MV3 service workers are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markInitializationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. +Because MV3 background contexts are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markInitializationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. ### New Permissions @@ -135,7 +135,7 @@ Because MV3 service workers are ephemeral and wake up frequently, developers mus | ---------------- | ----------------- | | N/A | N/A | -This API modifies the initialization lifecycle of the extension's service worker but does not grant any new access to user data or privileged browser features. Therefore, no new permissions are required. +This API modifies the initialization lifecycle of the extension's background context but does not grant any new access to user data or privileged browser features. Therefore, no new permissions are required. ### Manifest File Changes @@ -181,7 +181,7 @@ browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { }); ``` -This incurs significant performance overhead. It forces the browser to spin up the service worker environment and dispatch the event every single time it fires, only for the extension to immediately discard it. +This incurs significant performance overhead. It forces the browser to spin up the background context environment and dispatch the event every single time it fires, only for the extension to immediately discard it. #### Workaround B: The `removeListener` pattern @@ -202,7 +202,7 @@ While this prevents *future* unnecessary wake-ups (as the browser persists the u #### Workaround C: Listener substitution hack for dynamic filters -When an extension relies on dynamically configured event filters (e.g., user-defined URLs for `webNavigation`), it cannot register the filtered listener synchronously on a cold start because the filters haven't been loaded from storage yet. To avoid missing the event that triggered the service worker's wake-up, the extension must register a broad, unfiltered listener to act as a temporary catch-all, and later substitute it. +When an extension relies on dynamically configured event filters (e.g., user-defined URLs for `webNavigation`), it cannot register the filtered listener synchronously on a cold start because the filters haven't been loaded from storage yet. To avoid missing the event that triggered the background context's wake-up, the extension must register a broad, unfiltered listener to act as a temporary catch-all, and later substitute it. ```javascript // 1. Synchronously register an unfiltered catch-all to receive the waking event. @@ -231,7 +231,7 @@ Service workers on the open web handle initialization via the `install` and `act ## Implementation Notes -* The `async_initialization` manifest key allows the browser to know before spinning up the service worker that it should start queuing events *in the renderer*. +* The `async_initialization` manifest key allows the browser to know before spinning up the background context that it should start queuing events *in the renderer*. * The browser's event router must compute the union of the old persisted listener configurations and any newly evaluated configurations during startup to determine which events to forward to the initializing renderer. * Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markInitializationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. * When the initialization completion is signaled while the background script is still starting/executing, events are not queued and the usual event dispatch happens as soon as possible. From b46a3ac375b258ee3f95a3cdc40d2e28fb0b2efc Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Wed, 6 May 2026 14:48:36 -0700 Subject: [PATCH 10/12] markListenersRegistrationComplete --- proposals/async-listener-registration.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index 26eca4cbf..fce3f6df0 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -2,7 +2,7 @@ **Summary** -An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their background context during its initialization phase via a manifest opt-in field and a `markInitializationComplete()` API, enabling the asynchronous registration of event listeners. +An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their background context during its initialization phase via a manifest opt-in field and a `markListenersRegistrationComplete()` API, enabling the asynchronous registration of event listeners. **Document Metadata** @@ -63,7 +63,7 @@ namespace runtime { * * This method is only available to the background script context. */ - export function markInitializationComplete(): void; + export function markListenersRegistrationComplete(): void; } ``` @@ -75,10 +75,10 @@ namespace runtime { * **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the background context's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). * **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. * **Listener registration:** Developers perform their potentially asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. -* **State commit & completion API:** The extension must call `browser.runtime.markInitializationComplete()` once its potentially async setup is complete. At that moment: +* **State commit & completion API:** The extension must call `browser.runtime.markListenersRegistrationComplete()` once its potentially async setup is complete. At that moment: 1. The old persisted routing state is **thrown away**. 2. The new listeners currently registered are committed to the browser as the new persisted routing state for future wake-ups. -* **Event flush:** Once `markInitializationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. +* **Event flush:** Once `markListenersRegistrationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. #### Example Flow @@ -92,7 +92,7 @@ browser.storage.local.get("shouldListen").then((settings) => { browser.tabs.onUpdated.addListener(handleTabUpdate); } // Signal the end of the initialization phase. - browser.runtime.markInitializationComplete(); + browser.runtime.markListenersRegistrationComplete(); }); ``` @@ -106,28 +106,28 @@ From here, execution resolves into one of two cases. If the storage promise resolves and `settings.shouldListen` evaluates to `true`: 1. The extension executes the `addListener()` call. -2. The extension calls `browser.runtime.markInitializationComplete()`. +2. The extension calls `browser.runtime.markListenersRegistrationComplete()`. 3. The browser commits the routing state (maintaining the `tabs.onUpdated` registration for future wake-ups). The renderer flushes its holding queue, and the queued `tabs.onUpdated` event is safely dispatched to the newly attached `handleTabUpdate` callback. No events were missed. ##### Case 2: The listener is not registered If the storage promise resolves but `settings.shouldListen` evaluates to `false`: 1. The extension skips the `addListener()` call. -2. The extension calls `browser.runtime.markInitializationComplete()`. +2. The extension calls `browser.runtime.markListenersRegistrationComplete()`. 3. The browser commits the routing state. Because the listener was omitted during this run, the browser overwrites and **clears** the old persisted routing state. The renderer flushes the queued event, which is harmlessly dropped since no JavaScript callback is attached. 4. Because the routing state is now cleared, future `tabs.onUpdated` events will no longer wake up the background context. This implicitly cleans up the routing state without the developer needing to explicitly call `removeListener()` (see Workaround B) or accept unnecessary wake-ups on future events (see Workaround A). #### Edge Cases * **Initialization failure fallback:** If the initialization phase fails (e.g., the worker crashes or hits a timeout before the completion API is called), the browser will abort the state commit. It will keep the previous persisted routing state to prevent leaving the extension in a broken state. -* **Late registration:** It is permissible to register new listeners after the `markInitializationComplete()` API call. If an extension does so, developers must understand the following lifecycle: +* **Late registration:** It is permissible to register new listeners after the `markListenersRegistrationComplete()` API call. If an extension does so, developers must understand the following lifecycle: * The browser will persist these "late" listeners across background context terminations to successfully trigger a future wake-up. - * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markInitializationComplete()` entirely replaces the routing state from the previous run. + * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markListenersRegistrationComplete()` entirely replaces the routing state from the previous run. * Therefore, any late-registered listeners from a previous run will be removed from the registered listeners during the next run's state commit unless the extension explicitly re-registers them before calling the completion API. #### Performance Considerations -Because MV3 background contexts are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markInitializationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. +Because MV3 background contexts are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markListenersRegistrationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. ### New Permissions @@ -158,7 +158,7 @@ This API does not expose any sensitive data or personally identifiable informati ### Abuse Mitigations -* **Indefinite hangs:** A malicious or poorly coded extension could fail to call `markInitializationComplete()` in an attempt to consume memory by forcing the browser to queue a massive number of events. This is mitigated by queuing the events in the renderer process, scoping the impact to the renderer only. The browser can also enforce an initialization timeout, falling back to the previous run's routing state if it expires. +* **Indefinite hangs:** A malicious or poorly coded extension could fail to call `markListenersRegistrationComplete()` in an attempt to consume memory by forcing the browser to queue a massive number of events. This is mitigated by queuing the events in the renderer process, scoping the impact to the renderer only. The browser can also enforce an initialization timeout, falling back to the previous run's routing state if it expires. * **Queue limits:** Implementations could enforce an upper bound on the event queue size, dropping the oldest events if the limit is exceeded during the wait phase to prevent unbounded memory growth. Again, because the queuing happens renderer-side, in the worst case scenario a misbehaving renderer can be killed. ## Alternatives @@ -233,7 +233,7 @@ Service workers on the open web handle initialization via the `install` and `act * The `async_initialization` manifest key allows the browser to know before spinning up the background context that it should start queuing events *in the renderer*. * The browser's event router must compute the union of the old persisted listener configurations and any newly evaluated configurations during startup to determine which events to forward to the initializing renderer. -* Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markInitializationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. +* Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markListenersRegistrationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. * When the initialization completion is signaled while the background script is still starting/executing, events are not queued and the usual event dispatch happens as soon as possible. ## Future Work From 91c3a3e2d855fe2f42db0f6c5826624da53a6c6b Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Thu, 7 May 2026 10:34:46 -0700 Subject: [PATCH 11/12] markListenerRegistration, async_listener_registration --- proposals/async-listener-registration.md | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index fce3f6df0..dbc2f25ef 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -2,7 +2,7 @@ **Summary** -An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their background context during its initialization phase via a manifest opt-in field and a `markListenersRegistrationComplete()` API, enabling the asynchronous registration of event listeners. +An API proposal to allow extensions to explicitly delay the dispatch of extension API events to their background context during its initialization phase via a manifest opt-in field and a `markListenerRegistrationComplete()` API, enabling the asynchronous registration of event listeners. **Document Metadata** @@ -46,7 +46,7 @@ Extensions which require dynamic or conditionally registered event listeners bas { "background": { "service_worker": "background.js", - "async_initialization": true + "async_listener_registration": true } } ``` @@ -63,7 +63,7 @@ namespace runtime { * * This method is only available to the background script context. */ - export function markListenersRegistrationComplete(): void; + export function markListenerRegistrationComplete(): void; } ``` @@ -71,14 +71,14 @@ namespace runtime { #### Initialization Lifecycle -* **Manifest opt-in:** Extensions declare `"async_initialization": true` nested inside the `"background"` key in their `manifest.json`. +* **Manifest opt-in:** Extensions declare `"async_listener_registration": true` nested inside the `"background"` key in their `manifest.json`. * **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the background context's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). * **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. * **Listener registration:** Developers perform their potentially asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. -* **State commit & completion API:** The extension must call `browser.runtime.markListenersRegistrationComplete()` once its potentially async setup is complete. At that moment: +* **State commit & completion API:** The extension must call `browser.runtime.markListenerRegistrationComplete()` once its potentially async setup is complete. At that moment: 1. The old persisted routing state is **thrown away**. 2. The new listeners currently registered are committed to the browser as the new persisted routing state for future wake-ups. -* **Event flush:** Once `markListenersRegistrationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. +* **Event flush:** Once `markListenerRegistrationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. #### Example Flow @@ -92,13 +92,13 @@ browser.storage.local.get("shouldListen").then((settings) => { browser.tabs.onUpdated.addListener(handleTabUpdate); } // Signal the end of the initialization phase. - browser.runtime.markListenersRegistrationComplete(); + browser.runtime.markListenerRegistrationComplete(); }); ``` Assume that during a previous run, `settings.shouldListen` was `true`, the listener was registered, and the browser persisted this routing state. The background context has since been terminated. -When a new `tabs.onUpdated` event fires, the browser checks the persisted state and wakes the background context. Because the manifest includes `"async_initialization": true`, the renderer process queues the waking event rather than attempting to dispatch it immediately. +When a new `tabs.onUpdated` event fires, the browser checks the persisted state and wakes the background context. Because the manifest includes `"async_listener_registration": true`, the renderer process queues the waking event rather than attempting to dispatch it immediately. From here, execution resolves into one of two cases. @@ -106,28 +106,28 @@ From here, execution resolves into one of two cases. If the storage promise resolves and `settings.shouldListen` evaluates to `true`: 1. The extension executes the `addListener()` call. -2. The extension calls `browser.runtime.markListenersRegistrationComplete()`. +2. The extension calls `browser.runtime.markListenerRegistrationComplete()`. 3. The browser commits the routing state (maintaining the `tabs.onUpdated` registration for future wake-ups). The renderer flushes its holding queue, and the queued `tabs.onUpdated` event is safely dispatched to the newly attached `handleTabUpdate` callback. No events were missed. ##### Case 2: The listener is not registered If the storage promise resolves but `settings.shouldListen` evaluates to `false`: 1. The extension skips the `addListener()` call. -2. The extension calls `browser.runtime.markListenersRegistrationComplete()`. +2. The extension calls `browser.runtime.markListenerRegistrationComplete()`. 3. The browser commits the routing state. Because the listener was omitted during this run, the browser overwrites and **clears** the old persisted routing state. The renderer flushes the queued event, which is harmlessly dropped since no JavaScript callback is attached. 4. Because the routing state is now cleared, future `tabs.onUpdated` events will no longer wake up the background context. This implicitly cleans up the routing state without the developer needing to explicitly call `removeListener()` (see Workaround B) or accept unnecessary wake-ups on future events (see Workaround A). #### Edge Cases * **Initialization failure fallback:** If the initialization phase fails (e.g., the worker crashes or hits a timeout before the completion API is called), the browser will abort the state commit. It will keep the previous persisted routing state to prevent leaving the extension in a broken state. -* **Late registration:** It is permissible to register new listeners after the `markListenersRegistrationComplete()` API call. If an extension does so, developers must understand the following lifecycle: +* **Late registration:** It is permissible to register new listeners after the `markListenerRegistrationComplete()` API call. If an extension does so, developers must understand the following lifecycle: * The browser will persist these "late" listeners across background context terminations to successfully trigger a future wake-up. - * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markListenersRegistrationComplete()` entirely replaces the routing state from the previous run. + * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markListenerRegistrationComplete()` entirely replaces the routing state from the previous run. * Therefore, any late-registered listeners from a previous run will be removed from the registered listeners during the next run's state commit unless the extension explicitly re-registers them before calling the completion API. #### Performance Considerations -Because MV3 background contexts are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markListenersRegistrationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. +Because MV3 background contexts are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markListenerRegistrationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. ### New Permissions @@ -139,13 +139,13 @@ This API modifies the initialization lifecycle of the extension's background con ### Manifest File Changes -Add an optional (default false) `async_initialization` field inside the `background` key. +Add an optional (default false) `async_listener_registration` field inside the `background` key. ```json { "background": { "service_worker": "background.js", - "async_initialization": true + "async_listener_registration": true } } ``` @@ -158,7 +158,7 @@ This API does not expose any sensitive data or personally identifiable informati ### Abuse Mitigations -* **Indefinite hangs:** A malicious or poorly coded extension could fail to call `markListenersRegistrationComplete()` in an attempt to consume memory by forcing the browser to queue a massive number of events. This is mitigated by queuing the events in the renderer process, scoping the impact to the renderer only. The browser can also enforce an initialization timeout, falling back to the previous run's routing state if it expires. +* **Indefinite hangs:** A malicious or poorly coded extension could fail to call `markListenerRegistrationComplete()` in an attempt to consume memory by forcing the browser to queue a massive number of events. This is mitigated by queuing the events in the renderer process, scoping the impact to the renderer only. The browser can also enforce an initialization timeout, falling back to the previous run's routing state if it expires. * **Queue limits:** Implementations could enforce an upper bound on the event queue size, dropping the oldest events if the limit is exceeded during the wait phase to prevent unbounded memory growth. Again, because the queuing happens renderer-side, in the worst case scenario a misbehaving renderer can be killed. ## Alternatives @@ -231,9 +231,9 @@ Service workers on the open web handle initialization via the `install` and `act ## Implementation Notes -* The `async_initialization` manifest key allows the browser to know before spinning up the background context that it should start queuing events *in the renderer*. +* The `async_listener_registration` manifest key allows the browser to know before spinning up the background context that it should start queuing events *in the renderer*. * The browser's event router must compute the union of the old persisted listener configurations and any newly evaluated configurations during startup to determine which events to forward to the initializing renderer. -* Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markListenersRegistrationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. +* Implementations must ensure that enqueued tasks are flushed and dispatched normally upon calling `markListenerRegistrationComplete()`. If the initialization fails before the method is called, the state commit is aborted, and the previous run's listener state is preserved in the browser process. * When the initialization completion is signaled while the background script is still starting/executing, events are not queued and the usual event dispatch happens as soon as possible. ## Future Work From 3154852cd35f6a3c0927c4968155df2c8b8c2153 Mon Sep 17 00:00:00 2001 From: Andrea Orru Date: Thu, 14 May 2026 12:48:38 -0700 Subject: [PATCH 12/12] Address several comments --- proposals/async-listener-registration.md | 101 ++++++++++++++++++----- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/proposals/async-listener-registration.md b/proposals/async-listener-registration.md index dbc2f25ef..ef7b21e96 100644 --- a/proposals/async-listener-registration.md +++ b/proposals/async-listener-registration.md @@ -36,6 +36,14 @@ This proposal introduces a mechanism to explicitly delay the finalization of the Extensions which require dynamic or conditionally registered event listeners based on asynchronous state, without resorting to unconditionally waking up the background context for events they may not care about. +### Non-goals / Out of Scope + +This proposal is scoped to extension API event listener registration in an extension background context. + +* It does not change the lifecycle of standard service worker events. +* It does not affect web platform API events or non-event extension APIs. +* It does not affect behavior in non-background extension contexts. + ## Specification ### Schema @@ -57,9 +65,9 @@ Extensions which require dynamic or conditionally registered event listeners bas namespace runtime { /** * Signals to the browser that asynchronous initialization is complete. - * Transitions the background context out of the initialization phase, replaces - * the previous run's persisted listeners with the newly registered ones, - * and flushes the event queue. + * Transitions the background context out of the listener registration phase, + * replaces the previous run's persisted listeners with the newly registered + * ones, and flushes the event queue. * * This method is only available to the background script context. */ @@ -69,18 +77,27 @@ namespace runtime { ### Behavior -#### Initialization Lifecycle +#### Listener Registration Lifecycle + +When an extension opts into `"async_listener_registration": true`, the browser enters a listener registration phase each time the background context starts. + +During this phase: + +1. The browser routes extension API events to the initializing background context if the event matches either: + * the previous persisted routing state; or + * any new listeners being registered during the current run. + +2. Routed events are queued instead of dispatched to JavaScript callbacks until listener registration is completed. + +3. The extension registers all listeners that should participate in the next persisted routing state. -* **Manifest opt-in:** Extensions declare `"async_listener_registration": true` nested inside the `"background"` key in their `manifest.json`. -* **Renderer-side queueing:** During the initialization phase, the browser routes incoming events that match the previously registered listeners to the background context's renderer process, which queues them instead of immediately attempting to dispatch them to JavaScript callbacks (which might not be attached yet). -* **Event routing (union of old and new):** While in the initialization phase, the browser will route an event to the renderer queue if it matches *either* the old persisted routing state (from the previous run) or any new listeners currently being registered by the executing script. -* **Listener registration:** Developers perform their potentially asynchronous setup (e.g., reading from storage) and call `addListener()` to attach their desired JavaScript callbacks *before* calling the completion API. -* **State commit & completion API:** The extension must call `browser.runtime.markListenerRegistrationComplete()` once its potentially async setup is complete. At that moment: - 1. The old persisted routing state is **thrown away**. - 2. The new listeners currently registered are committed to the browser as the new persisted routing state for future wake-ups. -* **Event flush:** Once `markListenerRegistrationComplete()` ends the initialization phase, the renderer flushes its queue, dispatching all held events to the newly registered JavaScript callbacks in FIFO order. +4. The extension calls `browser.runtime.markListenerRegistrationComplete();`. This atomically completes the listener registration phase: + * The previous persisted routing state is replaced with the current listener set. + * The background context leaves the listener registration phase. + * Queued events are flushed in FIFO order and dispatched against the current listener set. + * Queued events that no longer match any registered listener are discarded. -#### Example Flow +##### Example Flow To illustrate how these lifecycle changes resolve the timing issues of asynchronous setup, consider an extension that conditionally tracks tab updates based on an asynchronous storage read. @@ -102,14 +119,14 @@ When a new `tabs.onUpdated` event fires, the browser checks the persisted state From here, execution resolves into one of two cases. -##### Case 1: The listener is registered +###### Case 1: The listener is registered If the storage promise resolves and `settings.shouldListen` evaluates to `true`: 1. The extension executes the `addListener()` call. 2. The extension calls `browser.runtime.markListenerRegistrationComplete()`. 3. The browser commits the routing state (maintaining the `tabs.onUpdated` registration for future wake-ups). The renderer flushes its holding queue, and the queued `tabs.onUpdated` event is safely dispatched to the newly attached `handleTabUpdate` callback. No events were missed. -##### Case 2: The listener is not registered +###### Case 2: The listener is not registered If the storage promise resolves but `settings.shouldListen` evaluates to `false`: 1. The extension skips the `addListener()` call. @@ -117,17 +134,53 @@ If the storage promise resolves but `settings.shouldListen` evaluates to `false` 3. The browser commits the routing state. Because the listener was omitted during this run, the browser overwrites and **clears** the old persisted routing state. The renderer flushes the queued event, which is harmlessly dropped since no JavaScript callback is attached. 4. Because the routing state is now cleared, future `tabs.onUpdated` events will no longer wake up the background context. This implicitly cleans up the routing state without the developer needing to explicitly call `removeListener()` (see Workaround B) or accept unnecessary wake-ups on future events (see Workaround A). +#### Late Registrations + +Once `markListenerRegistrationComplete()` is called, event listener registration resumes its normal behavior for the running background context. By default, listeners added after completion also update the browser's persisted routing state so that the extension can be woken up for those events in the next run. + +A listener registered after `markListenerRegistrationComplete()` is a "late listener registration". Late listener registrations are persisted by default. This is intentional: it allows extensions to change runtime configuration after startup. For example, if a feature is enabled after listener registration has already completed, the extension can register the newly needed listener immediately and rely on that listener to wake the background context in the next run. + +On the next background context startup, however, `markListenerRegistrationComplete()` again performs a full replacement of the persisted routing state. Therefore, a late listener from a previous run remains persisted only if the extension re-registers it before the next successful call to `markListenerRegistrationComplete()`. + +If a late listener was accidentally persisted and is not re-registered during the next successful listener registration phase, it is removed from the persisted routing state at the next completion point. It may still cause an unnecessary wake-up before then. + +Listeners intended to be temporary or scoped only to the current background context run must be removed explicitly with `removeListener()`. This proposal does not introduce non-persistent / session-scoped listeners; that is listed as future work. + +##### Example Flow of a Late Registration + +After listener registration has completed, an extension might change a setting at runtime: + +```javascript +async function setShouldListen(shouldListen) { + await browser.storage.local.set({ shouldListen }); + + if (shouldListen) { + if (!browser.tabs.onUpdated.hasListener(handleTabUpdate)) { + browser.tabs.onUpdated.addListener(handleTabUpdate); + } + } else { + if (browser.tabs.onUpdated.hasListener(handleTabUpdate)) { + browser.tabs.onUpdated.removeListener(handleTabUpdate); + } + } +} +``` + +If `shouldListen` changes from `false` to `true` after `markListenerRegistrationComplete()` has already been called, the `tabs.onUpdated` listener is a late listener registration. It updates the persisted routing state and can wake the extension on a future `tabs.onUpdated` event. + +On the next startup, the extension is still expected to read `shouldListen` and re-register `handleTabUpdate` before calling `markListenerRegistrationComplete()`. If it does, the listener remains in the persisted routing state. Otherwise, the next successful completion replaces the state and removes the late listener. + +If `shouldListen` changes from `true` to `false`, the extension should call `removeListener()` so future `tabs.onUpdated` events do not unnecessarily wake the background context. + #### Edge Cases * **Initialization failure fallback:** If the initialization phase fails (e.g., the worker crashes or hits a timeout before the completion API is called), the browser will abort the state commit. It will keep the previous persisted routing state to prevent leaving the extension in a broken state. -* **Late registration:** It is permissible to register new listeners after the `markListenerRegistrationComplete()` API call. If an extension does so, developers must understand the following lifecycle: - * The browser will persist these "late" listeners across background context terminations to successfully trigger a future wake-up. - * On the subsequent wake-up, all relevant listeners must be re-registered. Calling `markListenerRegistrationComplete()` entirely replaces the routing state from the previous run. - * Therefore, any late-registered listeners from a previous run will be removed from the registered listeners during the next run's state commit unless the extension explicitly re-registers them before calling the completion API. #### Performance Considerations -Because MV3 background contexts are ephemeral and wake up frequently, developers must be aware that delaying initialization adds latency to event dispatch. If an extension relies on slow I/O before calling `markListenerRegistrationComplete()`, that latency penalty applies to every single event that triggers a cold start. Developers are heavily encouraged to use fast, local I/O for conditional listener registration during routine wake-ups. +Because MV3 background contexts are ephemeral and may wake up frequently, delaying listener registration can add latency to event dispatch on every cold start. If an extension performs slow I/O before calling `markListenerRegistrationComplete()`, that delay applies to events queued during the listener registration phase. Extensions are encouraged to keep listener registration work short and to prefer fast, local state for routine startup decisions. Where possible, extensions should call `markListenerRegistrationComplete()` as soon as the listener set for the current run has been determined. + +Delayed dispatch can also increase stale-state and time-of-check to time-of-use (TOCTOU) risks. Handlers for delayed events should revalidate assumptions before acting and should prefer stable identifiers where available. ### New Permissions @@ -238,4 +291,10 @@ Service workers on the open web handle initialization via the `install` and `act ## Future Work -N/A +A future proposal could allow an extension to register a listener that is active only for the current background context run and does not update persisted routing state. For example, this might be represented as an explicit listener option: + +```javascript +browser.tabs.onUpdated.addListener(handleTemporaryUpdate, { ...filter, persistent: false }); +``` + +This would address temporary listener use cases without changing the default behavior of late registrations in this proposal.