Need more advice on non-persistence

The background scripts article has some advice on coding for non-persistence, but not quite enough. I have a few questions:

  1. I’m not sure what the right event is for initializing code whenever the extension starts. At the moment I just use a bare init() function in the background script. Is that fine?

  2. My extension currently loads settings from storage at startup, which is then imported by various modules for their functions to refer to. Is the non-persistent way really having functions directly load relevant settings every time they are needed?

  3. Is dynamically importing modules for optional functionality worth doing for performance?

  1. you should use browser.runtime.onInstalled event to initially setup things like context menus and Alarms and they should persist even after browser restarts. That is, in theory and probably only in MV3. It’s not exactly well documented…

  2. yes, the background script will be completely reloaded, like if you just reopened closed page.
    It’s important to note that browsers are extremely fast these days so reading data from storage and executing some hundreds lines of javascript can be done in few milliseconds.
    So the benefit of not consuming any RAM while being “idle” is a big plus and re-running background script several times per day is actually a good tradeoff.

  3. it depends, but unless you are importing huge libraries, it’s probably not worth your time. Also it can be challenging in MV3…

And lastly, Firefox doesn’t support yet non-persistent background scripts in the release channel version, so unless you want to help with testing, you can just wait about 1 year when next ESR 114 is released.

https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/background#browser_compatibility
Only persistent pages are supported. As of version 101, extension developers can turn on event pages for testing in Manifest V2 using the extensions.eventPages.enabled preference and in Manifest V3 using the extensions.manifestV3.enabled preference.

1 Like

Let me get this straight, browser.runtime.onInstalled is every time the extension starts e.g. when Firefox starts, not when it’s “installed” e.g. from AMO?

To be more specific, at initialization my extension needs to take in the current state of the browser’s windows and tabs.

Edit:

Would browser.runtime.onStartup be the correct event in this case?

That was my reaction too!

But no, these things “persists” like a storage, across browser / PC restarts!
So in your on-install event you should set your periodic alarm and it will stay registered forever. And it’s same with context menus and some other API.

The onStartup may be problematic since it’s not really related to extension. I think if you disable and enable your extension, it won’t fire. Also if the browser keeps some instance running (maybe like an Edge), it may not fire if you close and reopen browser (I’m not sure where I read this).

So as you can see, things are pretty complicated and currently there is no “on-extension-start” event. But there are some workarounds with storage.session.

See also:
Best-practices for “event pages”
http://web.archive.org/web/20140302025634/https://developer.chrome.com/extensions/event_pages#best-practices

MV3 service workers: Running a function once at the start
https://groups.google.com/a/chromium.org/g/chromium-extensions/c/QjuCv-LRK2c/m/qYx70RYhAwAJ

1 Like

storage.session is not supported by Firefox though. What can we do?

Well, not yet, but it’s planned and with priority P2 it sounds promising to come out soon:

Hi! Can you share these workarounds please? I’m still not sure what the relevant event is.

Basically, since the session storage is bound to the extension lifetime, you can store a dummy value in it and then later check if the value is there.

For example, this is my helper file:

import {isRunningInBackground} from "../environment/context_detection";

browser.runtime.onStartup.addListener(() => {
  // NOTE: in MV3 the background script won't start without onStartup (or similar) listener
});

const KEY = '_ext_started';

// resolves to "TRUE" only if extension just booted. Resolves to "FALSE" if service worker just woke up OR if called from non-background script.
export const didExtensionStarted = (async () => {
  // only allowed in background:
  if (!isRunningInBackground) return false;
  // in MV3 we check runtime storage
  const {[KEY]: extensionStarted} = await browser.storage.session.get(KEY);
  if (extensionStarted) return false;
  // we save the time when extension started
  await browser.storage.session.set({[KEY]: Date.now()});
  return true;
})();

I see, thank you.

Another question: To replace top-level code that execute whenever an extension is restarted, do we need both runtime.onStartup and runtime.onInstalled calling the code for the same effect? As I understand from the docs, the former fires only when the browser (specifically the profile) starts, and the latter fires only when the extension is installed/updated.

This depends on what you want to run, in what browser and what cases you want to cover :upside_down_face:.

To be honest, I’m totally loosing track of what does work, where, in what browser version, and in what cases :smiley:.
Some API, that should be persistent and should be registered only in onInstalled handler are actually not persistent enough, for example Alarms or Context menus (maybe fixed already?) won’t survive disable/enable of the addon.

That’s why I’ve build the helper, because as far as I can tell, it’s the only sure way to cover all the “extension starting cases”:

  • when the browser starts (with extension present)
  • when extension is installed
  • when extension is updated
  • when extension is enabled after it was disabled

Hey all! I’m jumping into this thread a bit late, but I wanted to share some advice about handling background context initialization in event-based environments (event pages and service workers).

First, you should not perform general purpose background setup (such as retrieving state from browser.storage, binding event listeners, etc.) in extension lifecycle events such as runtime.onInstalled or runtime.onStartup. The rationale here is pretty simple: those events only cover some of the situations where the extensions background will start up. While these events are useful for things like migrating your IndexedDB store to a new schema, register context menus, or preparing data held in storage.session, they don’t cover general purpose extension startup. Why? Because they don’t need to.

The background script itself sets up the background environment without needing to rely on any special events. When you declare variables, define functions, or import modules in the root scope of your script, those operations directly affect the background environment as long as it’s alive. When the background is terminated, those environment-specific values are lost. This is the same basic idea as how a web page’s scripts initialize it’s JavaScript context when you open the page, the JavaScript context is destroyed when you close the page, and the page is re-initialized again when you navigate back to it.

So, with that in mind, I want to go back to the very start of this thread.

Assuming that init() function is invoked right after it’s defined, that will work just fine. But, it might be worth noting that you don’t even need to do that. The entire script is executed top to bottom when the background context is initialized, so the initialization logic doesn’t have be placed in a function or bound to a specific event.

To make this more concrete, let’s look at a demo extension that will make the current tab show an alert when the extension’s action button is clicked:

{
  "name": "Alert demo",
  "version": "1.0",
  "manifest_version": 3,
  "background": {
    "scripts": ["background.js"],
    "service_worker": "background.js"
  },
  "action": {},
  "permissions": [
    "scripting",
    "storage",
    "activeTab"
  ]
}

background.js

// Minimal MV3 polyfill to make this code work in Chrome and other browsers
globalThis.browser ??= chrome; 

browser.runtime.onInstalled.addListener(() => {
  browser.storage.local.set({greeting: "Hello, world!"});
});

browser.action.onClicked.addListener(async (tab) => {
  const { greeting } = await browser.storage.local.get("greeting");
  browser.scripting.executeScript({
    target: { tabId: tab.id },
    func: function(greeting, injectionTime) {
      alert(`${greeting} (${Date.now() - injectionTime}ms delay)`);
    },
    args: [greeting, Date.now()],
  });
});

In this extension we polyfilly the browser global, use a runtime.onInstalled listener to configure settings that will be applicable for the entire time the extension is installed, and bind an action.onClicked event listener.

Yes and no. You absolutely can load values on demand as I did in the previous demo, but you can also share values across event listeners, you just need to do a bit more ground work to keep things in sync. Unfortunately I’m a bit short on time right now, so I’ll have to follow up later with an example.

2 Likes

In order to avoid constantly re-requesting the same values and dealing with delays introduced by those async calls, we can cache values that we’ve retrieved from storage in memory by assigning them to a local variable. Let’s look at a concrete example.

Instead of reading greeting directly form storage and writing greeting directly to storage, let’s declare a global variable that we can use to reference the last known version of this variable.

// Minimal MV3 polyfill to make this script work in Chrome and other browsers
globalThis.browser ??= chrome;

// Store values as long as the background context is alive
let greeting;

Next, let’s tweak the onInstalled listener so that it assigns the default greeting to both storage.local and our in-memory variable.

// When the extension is first installed, save the default `greeting` to storage and in memory
// and the storage cache.
browser.runtime.onInstalled.addListener(() => {
  const DEFAULT_GREETING = "Hello, world!";

  greeting = DEFAULT_GREETING;
  browser.storage.local.set({greeting: DEFAULT_GREETING});
});

We’ll also want to set up a storage.onChanged listener so that our background context gets updated when another extension context updates the value in storage. For example, if a content script writes a new greeting to storage.local. Be aware, though, that this event will not fire for changes made in the same context. That’s why we had to manually set greeting in the previous block even though we’re setting up this event listener.

// Update greeting to reflect changes made by other
browser.storage.onChanged.addListener((changes, storageArea) => {
  if (storageArea === "local" && changes.greeting) {
    greeting = changes.greeting.newValue;
  }
});

And in our last bit of setup for greeting, we need to fetch the saved version of greeting from storage and save it to our global variable. We’re to save the promise returned by the storage.local.get() call for future reference.

// Fetch `greeting` from storage and cache it for use later
const greetingReady = browser.storage.local.get("greeting").then((data) => {
  greeting = data.greeting;
});

Finally, we need to apply two tweaks to our action.onClicked listener. First, we need to replace the promise that we’re awaiting at the top of the event handler. Since we’re not longer directly working against storage, we can remove await browser.storage.local.get(…). At the same time, we still need to wait for the global greeting variable to be populated, so we’ll await the greetingRead promise we created earlier.

I also changed the variable name in the injected function to prompt to make it a little easier to see that the function isn’t directly accessing the global greeting variable. Rather, it’s being passed into that function at injection time.

// When the action button is clicked, inject a content script to show an alert
browser.action.onClicked.addListener(async (tab) => {
  await greetingReady;

  browser.scripting.executeScript({
    target: { tabId: tab.id },
    func: function(prompt, injectionTime) {
      alert(`${prompt} (${Date.now() - injectionTime}ms delay)`);
    },
    args: [greeting, Date.now()],
  });
});

All together, we end up with a slightly more complex version of the previous extension, but one that doesn’t have to hit storage every time the action button is clicked.

Thanks @dotproto, this really helped solidify my mental model of a non-persistent/event-based environment.

Stuff like this should probably be made more explicit in the documentation. I had assumed that in an event-based system it’s somehow required (or at least ideal or optimal) to have all top-level execution be just event listeners.

So to drive the point home, any ephemeral data that doesn’t need to survive between environment lifetimes i.e. while the extension ‘sleeps’ (e.g. a report of current tabs) can be left in local variables, correct?

And is it also correct to say that what will persist between environment lifetimes are anything set up via extension APIs: menus, alarms, scripts, etc?

I hear you on wanting this to be more explicit in the documentation. I’m hoping to do a pass on the WebExtensions MDN content in the next few months to start identifying and improving them.

Correct! Persist what you need, forget the rest :slight_smile:

Yes, that’s a pretty good way to look at it. The main area where you may need to be a bit more careful is around the browser’s lifetime. Things can get a little more complicated when talking about what the browser will persist for the extension when the user quits and re-launches the browser later, so definitely test things around browser lifetimes to make sure everything is behaving as expected.

1 Like

Thank you. :100:

Would be helpful to have complete information about what happens at extension lifecycle events, like clicking the Reload button (apparently fires the browser.runtime.onInstalled event) and disabling/enabling an extension (seem to be same as auto-terminate/wake), and relevant things to look out for. Take for example these gotchas I recently encountered:

  • Registered scripts (browser.scripting) do not persist through a reload. So scripts would need to be re-registered at browser.runtime.onInstalled time.

  • The user can toggle permissions while an extension is disabled. So for permission awareness, simply listening to browser.permissions events is insufficient – you have to check at waking time too, right?

Is this where the following come in?

Right. This limitation also applies to extension updates. Here’s my understanding of why browsers behave this way:

In the case of traditional content scripts, browsers know which of the previously registered content scripts to keep and which to throw away because the extension statically declares them in the manifest. If there’s a registration that matches what previously existed, we can keep it and if there isn’t an exact match we can remove the old one.

The problem is that with content scripts that are dynamically registered using scripting.registerContentScripts() or scripting.updateContentScripts() may reference files that no longer exist in the extension’s package. But even if the browser checked for the file in the extension’s package, other logic in the extension may change such that the previously registered scripts are no longer relevant or may even work counter to the extension’s expected behavior. Since the decision to register the script was made at runtime by the extension itself and since the extension would have to register the appropraite set of content scripts in the case of a new installation, browsers defer to the extension to register the appropriate set of dynamic scripts at update time.

Right. I’ve done something similar in a wrapper library I wrote for the Storage API, where on initial script execution we pull data from browser.storage, cache it in a global variable, and watch for changes to storage while the context stays alive.

Definitely! In addition to browser.runtime.onStartup, you may also want to check browser.runtime.onInstalled to validate that a given setting persists across extension updates.

1 Like

Back to the use of the storage.session “workaround” – is it okay as an alternative to listening to onInstalled/onStartup? Example:

// background.js
onAwaken();

async function onAwaken() {
  const { initialized } = await browser.storage.session.get('initialized');
  if (!initialized) onInit();
  // Do things for every extension wake-up ...
}

function onInit() {
  browser.storage.session.set({ initialized: true });
  // Do things for the extension starting up ...
}

Unfortunately, no. browser.runtime.onStartup only covers when the browser starts a new session for this profile, not when the extension starts. But even if it did get called when the background context starts up, it still wouldn’t block other event handlers from being called.

If you’re targeting Chrome and Safari, you may be able to work around this using the localStorage API instead of browser.storage. Unfortunately this won’t work in Chrome because service workers don’t have access to the localStorage API.

To illustrate how this would work, here’s a demo where the user’s main interaction is clicking the extension’s action icon. If the user has granted the extension permission to access "*://example.com/*", then clicking the browser action will open a new tab for https://example.com. If they haven’t, clicking the browser action will instead trigger a permission request for "*://example.com/*" and, if it is granted, open a new tab as expected.

manifest.json

{
  "name": "Sync Listener Workaround",
  "version": "1.0",
  "manifest_version": 3,
  "background": {
    "scripts": ["background.js"]
  },
  "action": {},
  "host_permissions": ["https://example.com/*"]
}

background.js

// Global cache of permissions granted to the extension. This logic should
// appear before any event listeners that need to check permissions grants.
//
// CAUTION: Sub-arrays will be empty for a short period immediately after first
// installation!
const permissions = (() => {
  const perms = localStorage.getItem("cachedPermissions");
  if (perms) {
    return JSON.parse(perms);
  } else {
    return {
      permissions: [],
      origins: [],
    };
  }
})();

// Update cached `permissions` when this context starts. This covers both
// initial installation and errors related to the onAdded/onRemoved listeners
// that might creep in over time.
browser.permissions.getAll().then((perms) => {
  Object.assign(permissions, perms);
  localStorage.setItem("cachedPermissions", JSON.stringify(perms));
});

// Append added permissions to the `permissions` cache & update localStorage
browser.permissions.onAdded.addListener((permsAdded) => {
  for (const type in permsAdded) {
    for (const key in permsAdded[type]) {
      const perm = permsAdded[type][key];
      if (!permissions[type].includes(perm)) {
        permissions[type].push(perm);
      }
    }
  }
  localStorage.setItem("cachedPermissions", JSON.stringify(permissions));
});

// Drop removed permissions from the `permissions` cache & update localStorage
browser.permissions.onRemoved.addListener((permsRemoved) => {
  for (const type in permsRemoved) {
    for (const key in permsRemoved[type]) {
      const perm = permsRemoved[type][key];
      let index = permissions[type].indexOf(perm);
      if (index !== -1) {
        permissions[type].splice(index, 1); // delete 1 entry at "index"
      }
    }
  }
  localStorage.setItem("cachedPermissions", JSON.stringify(permissions));
});

browser.action.onClicked.addListener(() => {
  if (permissions.origins.includes("https://example.com/*>")) {
    browser.tabs.create({ url: 'https://example.com/' })
  } else {
    browser.permissions.request({origins: ["https://example.com/*>"]}, granted => {
      if (granted) {
        browser.tabs.create({ url: 'https://example.com/' })
      }
    });
  }
});