Skip to content

Instantly share code, notes, and snippets.

@domenic
Last active May 14, 2025 04:40
Show Gist options
  • Save domenic/c5bd38339f33b49120ae11b3b4af5b9b to your computer and use it in GitHub Desktop.
Save domenic/c5bd38339f33b49120ae11b3b4af5b9b to your computer and use it in GitHub Desktop.
Extended lifetime shared workers

Extended lifetime shared workers

This proposal is being discussed in whatwg/html#10997.

We propose adding a new option to the SharedWorker constructor that serves as a request to extend its lifetime after all current clients have unloaded:

const sharedWorker = new SharedWorker(url, { extendedLifetime: true });

The primary use case here is to allow pages to perform some async work that requires JavaScript after a page unloads, without needing to rely on a service worker.

Motivation

It's a known issue that many sites want to perform some work during document unloading. This usually includes writing to storage, or sending information to servers. (Previous discussion: whatwg/html#963.)

Some of the simpler use cases are solved by APIs like fetch(..., { keepalive: true }), or by using synchronous storage APIs like localStorage.

But the more complex cases, such as writing to async storage APIs like IndexedDB, or performing some sort of async operation before the HTTP request, require more work. Concrete examples of such pre-request async operations include using WebCrypto to hash/encrypt data before sending it, or using CompressionStream to compress it.

document.addEventListener("pagehide", async () => {
  // These work, because they're sync
  fetch("/send-analytics", { method: "POST", body: analyticsData, keepalive: true });
  localStorage.setItem("visitCount", localStorage.getItem("visitCount") + 1);

  // This does not work, because the document finishes unloading before the promises comes back.
  const encryptedData = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv: new Uint8Array(12) }, 
    cryptoKey, 
    analyticsData
  );
  fetch("/send-analytics", { method: "POST", body: encryptedData, keepalive: true });

  // This does not work, because the document finishes unloading before the event handler comes back.
  const transaction = db.transaction("analytics", "readwrite");
  const store = transaction.objectStore("analytics");
  const request = store.get("visitCount");
  request.onsuccess = (event) => {
    const newCount = (event.target.result || 0) + 1;
    store.put(newCount, "visitCount");
  };
});

The platform's existing solution: service workers

The current best solution the platform offers for this is service workers. By sending messages to the service worker, the service worker can then use its ability to run in the background to perform the appropriate actions. The message can carry along any data necessary to perform those actions, e.g., the not-yet-encrypted-or-compressed payload, or the user's unsaved changes that need to be written to IndexedDB.

However, requiring service workers is pretty heavyweight for this use case. Even if there are no fetch handlers, the disk space consumption of the service worker registration database means this is hard to deploy at scale. And, it uses up a process's worth of memory—not only during unload time, when the work is being performed, but the entire time any document from that origin is open.

Additionally, the developer experience of service worker registration, installation, upgrading, client claiming, etc. is a lot to manage, when the goal is to just run some background code after unload. For example, they require a separate same-origin service worker file being hosted, instead of allowing creation from blob: URLs. This means that libraries for this functionality need to consist of two files, not just one. (And it gets worse if the library needs to be able to integrate with a site's existing service worker script!)

How extended lifetime shared workers solve this

The extended lifetime shared workers proposal would allow sites to relocate this async work into a shared worker. As long as the site indicates to the browser, using { extendedLifetime: true }, that the shared worker should stay alive after document unloading, then the shared worker becomes a perfect place to do this work.

Shared workers are a natural fit for this, as they already have a lifetime that is independent of their client documents. In fact, the HTML Standard already encourages implementations to keep shared workers alive for a short time after document unloading, so that navigating between same-origin pages doesn't tear down and then re-create the shared worker. The extended lifetime proposal just extends this by suggesting that, even if the user is not navigating to a same-origin destination, the user agent should keep the shared worker alive for some amount of time, so that async work can be finished.

Additionally, unlike service workers, shared workers can easily be created from blob: URLs. This makes it simple for library code to inject this sort of async work on unload, as follows:

const sharedWorkerScript = `
  const transaction = db.transaction("analytics", "readwrite");
  const store = transaction.objectStore("analytics");
  const request = store.get("visitCount");
  request.onsuccess = (event) => {
    const newCount = (event.target.result || 0) + 1;
    store.put(newCount, "visitCount");
  };
`;

document.addEventListener("pagehide", () => {
  const blob = new Blob([sharedWorkerScript], { type: "text/javascript" });
  const blobURL = URL.createObjectURL(blob);
  new SharedWorker(blobURL, { extendedLifetime: true });
});

Slightly more advanced code can even get values passed in, e.g.:

const sharedWorkerScript = `
  self.onconnect = e => {
    const port = e.ports[0];
    port.onmessage = async (e) => {
      const { cryptoKey, analyticsData } = e.data;

      const encryptedData = await crypto.subtle.encrypt(
        { name: "AES-GCM", iv: new Uint8Array(12) }, 
        cryptoKey, 
        analyticsData
      );
      fetch("/send-analytics", { method: "POST", body: encryptedData, keepalive: true });
    };
  };
`;

document.addEventListener("pagehide", () => {
  const blob = new Blob([sharedWorkerScript], { type: "text/javascript" });
  const blobURL = URL.createObjectURL(blob);

  const worker = new SharedWorker(blobURL, { extendedLifetime: true });
  worker.port.start();
  worker.port.postMessage({ cryptoKey, analyticsData });
});

Alternatives considered

In addition to having websites continue to use service workers, which is disadvantageous for the reasons explained above, we considered the following alternative ways of solving this problem.

A new type of worklet

Instead of reusing shared workers, we could introduce a PageHideWorklet. This would be a special type of worklet which a document would register early in its lifetime. Then, during unload, right after firing the pagehide event, the browser spins up this worklet and runs its code. The document would need to synchronously set the data that the worklet plans to consume, either continuously throughout the document's lifetime, or in the pagehide handler (or both). But the worklet could run asynchronously for some amount of time.

On the implementation level, this could be implemented either with a separate process for the worklet, which gets spun up at unload time, or it could be implemented with an in-process worklet plus some code that keeps the unloading document's process alive, even while stopping its event loop and freeing up most of its resources.

We decided against this for a few reasons:

  • It does not fit with the processing model of existing worklets, which do not have an event loop. That is, all existing worklets are about performing synchronous work, with asynchronous work explicitly discouraged.
  • We'd have to ensure that all the relevant APIs, such as fetch and IndexedDB, are appropriately exposed in this worklet type.
  • In general, introducing a new type of global scope is a lot of work.

Overall, it's not clear what the advantages of this approach would be, when SharedWorker already exists and already is almost perfectly aligned with the use case.

Some sort of extendable pagehide event handler

The idea: inside a pagehide event, event.waitUntil(promise) would allow you to extend the document's lifetime and continue running JavaScript, while the document unloads. This would be up to some implementation-defined limit.

In parallel, the user agent would be loading the new document. This would not block loading the new document in any way: even if the new document completely finishes before the promise from the old document settles, we could visibly swap in the new document, while the old document continues running. It is sort of like keeping the old document in bfcache, except JavaScript continues to run. Chromium already has this sort of document state for ~3 seconds for some cross-process navigations, and we believe other implementations might as well.

This is probably the most convenient option for web developers, as they can colocate all their code into the pagehide handler. But keeping unloaded documents alive in that way, even with opt in, is scary. (A Mozilla representative phrased it as "terrifying lifetime implications".) And probably the memory consumed by the document, with all the DOM and JS objects its built up throughout its lifetime, is quite high. This option would not allow freeing that memory until all the async processes are finished.

Relationship to other work

It's worth noting that there is other ongoing work on landing the fetchLater() API, which might seem to serve similar purposes. Both are intended to help with cases like analytics where data needs to be sent even after a page unloads.

However, while the use cases are related, they are separate. fetchLater() is an upgrade over fetch()-in-pagehide, because it can be made more reliable by setting up the fetch ahead of time, and putting more in the browser's hands.

But, the cases this proposal is useful for are ones where neither fetchLater() nor fetch()-in-pagehide can work:

  • Non-fetch use cases, like async storage (e.g., writing to IndexedDB)
  • Cases where async steps (like encryption or compression) are required before fetching. (In some cases you can try to do these async steps before calling fetchLater(), but there's a chance the page will be unloaded during your async steps, and then you'll lose the data.)

Details

How long do extended lifetime shared workers stay alive?

We propose that extended lifetime shared workers with no clients stay alive for exactly the same amount of time that a service worker stays alive, when it has no clients. That is, this is intended to give feature parity with service workers. This is currently implementation-defined, allowing implementations to make appropriate tradeoffs between allowing background processing and respecting user privacy and resource usage.

For example, a valid implementation strategy for both service workers and extended lifetime shared workers would be to immediately shut down the workers when there are no clients. As long as the implementation uses the same strategy for both, this just represents a very strict choice.

In practice, we expect most implementations to use a timeout-based strategy. E.g., Chromium currently uses 30 seconds.

Mismatched expectedLifetime options

Shared workers are, by design, possible to share between multiple clients. Although we expect that the main users of extendedLifetime will not necessarily want to share the worker between multiple clients, we need to design this feature to play well with the possibility.

In particular, what happens if different clients pass in the same URL (or (URL, name) pair), but with different extendedLifetime options?

There are a few possibilities here, such as:

  1. If >=1 new SharedWorker() invocation includes { extendedLifetime: true }, then we treat the shared worker as having an extended lifetime. Basically, any client can extend the lifetime at any time.

  2. A shared worker's extended lifetime is specified as being relative to the clients that requested that extended lifetime. If page A requests extended lifetime, and page B doesn't, and then page A disappears, and then 10 minutes later page B disappears, the worker shuts down immediately after page B, since page B did not care about extended lifetime.

  3. Only the first new SharedWorker() invocation controls the lifetime. The 2nd onward have extendedLifetime ignored. (And maybe we would log a console warning explaining that it was ignored.)

  4. Only the first new SharedWorker() invocation controls the lifetime. All others have to match, and if they don't match, we throw an exception or fail the worker creation.

We've chosen route (4) for now, as this matches the behavior of other options to the SharedWorker constructor like type or credentials. And, it's easy to move to one of (1)-(3) later from (4), if use cases appear.

Connecting to an extended lifetime shared worker with zero clients

A similarly-detailed question is what should happen if an extended lifetime shared worker is running with no clients, and then a new client connects to the same URL (or (URL, name) pair):

  1. The new client should connect to the existing instance, and extend that instance's lifetime appropriately.

  2. A new shared worker is created, leaving the existing shared worker to expire.

We believe either of these behaviors could work for our main use cases. However, (1) is slightly less wasteful of resources like memory, and could be more convenient for other scenarios, so that is our current plan.

Stakeholder feedback

  • W3C TAG: to be filed
  • Browser engines:
  • Web developers: The general problem of performing asynchronous work during unload has been known to be problematic for a long time. We're working on getting signals on this particular proposal.
  1. What information does this feature expose, and for what purposes?

It does not expose any information.

  1. Do features in your specification expose the minimum amount of information necessary to implement the intended functionality?

Yes.

  1. Do the features in your specification expose personal information, personally-identifiable information (PII), or information derived from either?

No.

  1. How do the features in your specification deal with sensitive information?

It does not.

  1. Does data exposed by your specification carry related but distinct information that may not be obvious to users?

No.

  1. Do the features in your specification introduce state that persists across browsing sessions?

No. It persists across page loads, but not browsing sessions.

  1. Do the features in your specification expose information about the underlying platform to origins?

No.

  1. Does this specification allow an origin to send data to the underlying platform?

No.

  1. Do features in this specification enable access to device sensors?

No.

  1. Do features in this specification enable new script execution/loading mechanisms?

Not really. It builds on the existing shared worker infrastructure, including the specification's existing carveout for keeping shared workers alive beyond their clients' lifetimes. It just provides an explicit way to request that they be kept alive longer, for the same duration as a service worker would be.

  1. Do features in this specification allow an origin to access other devices?

No.

  1. Do features in this specification allow an origin some measure of control over a user agent's native UI?
 No.
  1. What temporary identifiers do the features in this specification create or expose to the web?

None.

  1. How does this specification distinguish between behavior in first-party and third-party contexts?

It does not.

  1. How do the features in this specification work in the context of a browser’s Private Browsing or Incognito mode?

The same as in normal mode.

  1. Does this specification have both "Security Considerations" and "Privacy Considerations" sections?

The HTML Standard does discuss the security and privacy implications of shared workers, and we anticipate adding appropriate wording about this addition in those places.

  1. Do features in your specification enable origins to downgrade default security protections?

No.

  1. What happens when a document that uses your feature is kept alive in BFCache (instead of getting destroyed) after navigation, and potentially gets reused on future navigations back to the document?

In some implementations, shared workers prevent bfcaching. (But, this is not required by the specification.)

This feature might make that slightly worse, since it introduces a new motivation to use shared workers.

For this reason, we're hopeful that implementations that add this feature first work to get rid of shared workers as a bfcache blocking reason. (That is Chromium's plan, at least.)

  1. What happens when a document that uses your feature gets disconnected?

Such documents get removed from the clients list for the shared worker, and thus start the countdown before the extended lifetime shared worker is destroyed.

  1. Does your spec define when and how new kinds of errors should be raised?

It defines no new types of errors.

  1. Does your feature allow sites to learn about the user's use of assistive technology?

No.

  1. What should this questionnaire have asked?

Seems fine.

@wanderview
Copy link

We propose that extended lifetime shared workers with no clients stay alive for exactly the same amount of time that a service worker stays alive, when it has no clients.

It seems like this would double the amount of time that a site could do background processing without a client window. Consider, if there is a server worker controlling the shared worker then you get 1) the extra time for the shared worker and then 2) after the shared worker dies you get the current service time with no clients.

@wanderview
Copy link

Is the extra time guaranteed? Does this make launching SharedWorker on android harder?

@domenic
Copy link
Author

domenic commented May 13, 2025

Let's keep the discussion centralized in whatwg/html#10997. I'll copy those questions over there.

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