Search code examples
javascriptgoogle-chromegoogle-chrome-extensionrequestxmlhttprequest

Persistent Service Worker in Chrome Extension


I need to define my Service Worker as persistent in my Chrome extension because I'm using the webRequest API to intercept some data passed in a form for a specific request, but I don't know how I can do that. I've tried everything, but my Service Worker keeps unloading.

How can I keep it loaded and waiting until the request is intercepted?


Solution

  • Table of contents

    • Description and known problems

    • Workarounds:

      • Bug exploit
      offscreen API
      nativeMessaging API
      WebSocket API
      chrome messaging API
      • Dedicated tab

    • Caution


    Service worker (SW) can't be persistent by definition and the browser must forcibly terminate all its activities/requests after a certain time, which in Chrome is 5 minutes. The inactivity timer (i.e. when no such activities are ongoing) is even shorter: 30 seconds.

    Chromium team currently considers this behavior good (the team relaxes some aspects though occasionally e.g. Chrome 114 prolongs chrome.runtime port after each message), however this is only good for extensions that observe infrequent events, which run just a few times a day thus reducing browser's memory footprint between the runs (for example, webRequest/webNavigation events with urls filter for a rarely visited site). These extensions can be reworked to maintain the state, example. Unfortunately, such an idyll is unsustainable in many cases:

    • Problem: worker dies in the middle of a long-running fetch().

    • Problem: the worker randomly stops waking up for events.

    • Problem: Chrome 106 and older doesn't wake up SW for webRequest events.

    • Problem: Chrome 109 and older doesn't prolong SW lifetime for a new chrome API event.

    • Problem: worse performance than MV2 in case the extension maintains a remote connection or the state (variables) takes a long time to rebuild or you observe frequent events like these:

      • chrome.tabs.onUpdated/onActivated,
      • chrome.webNavigation if not scoped to a rare url,
      • chrome.webRequest if not scoped to a rare url or type,
      • chrome.runtime.onMessage/onConnect for messages from content script in all tabs.

      Starting SW for a new event is essentially like opening a new tab. Creating the environment takes ~50ms, running the entire SW script may take 100ms (or even 1000ms depending on the amount of code), reading the state from storage and rebuilding/hydrating it may take 1ms (or 1000ms depending on the complexity of data). Even with an almost empty script it'd be at least 50ms, which is quite a huge overhead to call the event listener, which takes only 1ms.

      SW may restart hundreds of times a day, because such events are generated in response to user actions that have natural gaps in them e.g. clicked a tab then wrote something, during which the SW is terminated and restarted again for a new event thus wearing down CPU, disk, battery, often introducing a frequent perceivable lag of the extension's reaction.


    ⭕ Bug exploit in Chrome 110+

    Calling any asynchronous chrome API keeps the worker running for 30 seconds more. The bug was officially considered a feature, so it should stay that way for several years before the team can admit their mistake and fix it.

    // background.js, keep running while doing something

    const keepAlive = (i => state => {
      if (state && !i) {
        if (performance.now() > 20e3) chrome.runtime.getPlatformInfo();
        i = setInterval(chrome.runtime.getPlatformInfo, 20e3);
      } else if (!state && i) {
        clearInterval(i);
        i = 0;
      }
    })();
    
    async function doSomething() {
      try {
        keepAlive(true);
        const res = await (await fetch('........')).text();
        // ...........
      } catch (err) {
        // ..........
      } finally {
        keepAlive(false);
      }
    }
    

    // background.js, keep running forever

    const keepAlive = () => setInterval(chrome.runtime.getPlatformInfo, 20e3);
    chrome.runtime.onStartup.addListener(keepAlive);
    keepAlive();
    

    ⭕ Offscreen API in Chrome 109+

    Courtesy of Keven Augusto.

    In Chrome 109 and newer you can use offscreen API to create an offscreen document and send some message from it every 30 second or less, to keep service worker running. Currently this document's lifetime is not limited (only audio playback is limited, which we don't use), but it's likely to change in the future.

    • manifest.json

        "permissions": ["offscreen"]
      
    • offscreen.html

      <script src="offscreen.js"></script>
      
    • offscreen.js

      setInterval(async () => {
        (await navigator.serviceWorker.ready).active.postMessage('keepAlive');
      }, 20e3);
      
    • background.js

      async function createOffscreen() {
        await chrome.offscreen.createDocument({
          url: 'offscreen.html',
          reasons: ['BLOBS'],
          justification: 'keep service worker running',
        }).catch(() => {});
      }
      chrome.runtime.onStartup.addListener(createOffscreen);
      self.onmessage = e => {}; // keepAlive
      createOffscreen();
      

    nativeMessaging in Chrome 105+

    In Chrome 105 and newer the service worker will run as long as it's connected to a nativeMessaging host via chrome.runtime.connectNative. If the host process is terminated due to a crash or user action, the port will be closed, and the SW will terminate as usual. You can guard against it by listening to port's onDisconnect event and call chrome.runtime.connectNative again.

    ⭕ WebSocket API in Chrome 116+

    Chrome 116 and newer: exchange WebSocket messages less than every 30 seconds to keep it active, e.g. every 25 seconds.

    ⭕ Pinging another tab

    Downsides:

    • The need for an open web page tab
    • Broad host permissions (like <all_urls> or *://*/*) for content scripts which puts most extensions into the slow review queue in the web store.

    Warning! If you already connect ports, don't use this workaround, use another one for ports below.

    Warning! Also implement the workaround for sendMessage (below) if you use sendMessage.

    • manifest.json, the relevant part:

        "permissions": ["scripting"],
        "host_permissions": ["<all_urls>"],
        "background": {"service_worker": "bg.js"}
      
      
    • background service worker bg.js:

      const onUpdate = (tabId, info, tab) => /^https?:/.test(info.url) && findTab([tab]);
      findTab();
      chrome.runtime.onConnect.addListener(port => {
        if (port.name === 'keepAlive') {
          setTimeout(() => port.disconnect(), 250e3);
          port.onDisconnect.addListener(() => findTab());
        }
      });
      async function findTab(tabs) {
        if (chrome.runtime.lastError) { /* tab was closed before setTimeout ran */ }
        for (const {id: tabId} of tabs || await chrome.tabs.query({url: '*://*/*'})) {
          try {
            await chrome.scripting.executeScript({target: {tabId}, func: connect});
            chrome.tabs.onUpdated.removeListener(onUpdate);
            return;
          } catch (e) {}
        }
        chrome.tabs.onUpdated.addListener(onUpdate);
      }
      function connect() {
        chrome.runtime.connect({name: 'keepAlive'})
          .onDisconnect.addListener(connect);
      }
      
    • all your other extension pages like the popup or options:

      ;(function connect() {
        chrome.runtime.connect({name: 'keepAlive'})
          .onDisconnect.addListener(connect);
      })();
      

    If you also use sendMessage

    In Chrome 99-101 you need to always call sendResponse() in your chrome.runtime.onMessage listener even if you don't need the response. This is a bug in MV3. Also, make sure you do it in less than 5 minutes time, otherwise call sendResponse immediately and send a new message back via chrome.tabs.sendMessage (to the tab) or chrome.runtime.sendMessage (to the popup) after the work is done.

    If you already use ports e.g. chrome.runtime.connect

    Warning! If you also connect more ports to the service worker you need to reconnect each one before its 5 minutes elapse e.g. in 295 seconds. This is crucial in Chrome versions before 104, which killed SW regardless of additional connected ports. In Chrome 104 and newer this bug is fixed but you'll still need to reconnect them, because their 5-minute lifetime hasn't changed, so the easiest solution is to reconnect the same way in all versions of Chrome: e.g. every 295 seconds.

    • background script example:

      chrome.runtime.onConnect.addListener(port => {
        if (port.name !== 'foo') return;
        port.onMessage.addListener(onMessage);
        port.onDisconnect.addListener(deleteTimer);
        port._timer = setTimeout(forceReconnect, 250e3, port);
      });
      function onMessage(msg, port) {
        console.log('received', msg, 'from', port.sender);
      }
      function forceReconnect(port) {
        deleteTimer(port);
        port.disconnect();
      }
      function deleteTimer(port) {
        if (port._timer) {
          clearTimeout(port._timer);
          delete port._timer;
        }
      }
      
    • client script example e.g. a content script:

      let port;
      function connect() {
        port = chrome.runtime.connect({name: 'foo'});
        port.onDisconnect.addListener(connect);
        port.onMessage.addListener(msg => {
          console.log('received', msg, 'from bg');
        });
      }
      connect();
      

    ⭕ A dedicated tab

    Instead of using the SW, open a new tab with an extension page inside, so this page will act as a "visible background page" i.e. the only thing the SW would do is open this tab. You can also open it from the action popup.

    chrome.tabs.create({url: 'bg.html'})
    

    It'll have the same abilities as the persistent background page of ManifestV2 but a) it's visible and b) not accessible via chrome.extension.getBackgroundPage (which can be replaced with chrome.extension.getViews).

    Downsides:

    • consumes more memory,
    • wastes space in the tab strip,
    • distracts the user,
    • when multiple extensions open such a tab, the downsides snowball and become a real PITA.

    You can make it a little more bearable for your users by adding info/logs/charts/dashboard to the page and also add a beforeunload listener to prevent the tab from being accidentally closed.

    ⭕ Caution regarding persistence

    Make sure you only enable the keep-alive for the duration of a critical task and disable it afterwards so that your extension doesn't unnecessarily consume the memory when unused.

    Save/restore the state (variables) in some storage to guard against a crash, example.

    Note that you shouldn't make your worker persistent just to simplify state/variable management. Do it only to restore the performance worsened by restarting the worker in case your state is very expensive to rebuild or if you hook into frequent events listed in the beginning of this answer.