w3c / ServiceWorker

Service Workers
https://w3c.github.io/ServiceWorker/
Other
3.63k stars 316 forks source link

consider allowing multiple worker thread instances for a single registration #756

Open wanderview opened 8 years ago

wanderview commented 8 years ago

Currently gecko has a bug where multiple service worker threads can be spun up for the same registration. This can happen when windows in separate child processes open documents controlled by the same registration. Per the spec, the FetchEvents should be dispatched to the same worker instance. In practice, we spin up different work threads for each child process and dispatch there.

While discussing how to fix this, we started to wonder if its really a problem. Service workers already discourage shared global state since the thread can be killed at any time. If the service worker script is solely using Cache, IDB, etc to store state, then running multiple thread instances is not a problem.

It seems the main issue where this could cause problems is for code that wants to use global variables while holding the worker alive using waitUntil() or respondWith(). This is theoretically observable by script, but seems to have limited useful applications.

How would people feel about loosening the current spec to allow a browser to spin up more than one worker thread for a single registration? When to execute multiple threads and how many would be up to the browser implementation.

flaki commented 8 years ago

Service workers already discourage shared global state since the thread can be killed at any time. If the service worker script is solely using Cache, IDB, etc to store state, then running multiple thread instances is not a problem. I really like this.

Are there any apparent "low-hanging fruit" gains expected from allowing this, except for the obvious gains from reduced complexity, not having to make sure there is only one thread running of the SW script?
I can see this becoming a "want"-ed feature in the (maybe-not-so-distant) future where very high number of (potentially low- or medium-speed) cores will be present in mainstream devices, and sites with complex SW-s become a thing (a sort-of client-side server, if you will). In those cases I can see this becoming an actually useful features, just like how load-balancing between stateless (or sticky-state) high-demand servers operate now.

mkruisselbrink commented 8 years ago

My initial reaction was that allowing this would never work. There are too many places in the spec that assume there is only one active ServiceWorker, and all kinds of things would break in confusing ways if the ServiceWorker you can postMessage to via controller.postMessage is not necessarily the same worker as the worker who is handling fetch events (or there might even be multiple workers handling fetch events for a single client). But after thinking about it a bit more I don't think any of the problems that would occur by allowing multiple copies of the same service worker to be running at the same time are truly insurmountable.

But I'm also not sure if there really is much value in allowing this. I imagine this could be useful in situations where a serviceworker wants to do some relatively heavy CPU bound task in the process of handling some fetch (or other) event, and we don't want to block other fetch requests on that task?

I think what we should be supporting for such situations is that the SW can spin up a dedicated/shared worker, whose lifetime is linked to the lifetime of the SW (or maybe to the event it is currently handling, but just the SW would work fine; after all it won't get killed until the fetch event the processing is for has finished), just like a regular website would have to spin up a separate worker to do heavy CPU bound tasks in response to an event.

And if we want to allow long-running CPU bound tasks in response to an event, which last past a response has been send to a fetch event, I'm not sure if those should be allowed without some kind of UI presence (for example allowing a new kind of Notification with progress indicator to serve as the UI that keeps a shared worker alive).

wanderview commented 8 years ago

To be clear, I'm only suggesting we state the browser may spin up multiple SW instances, but not make it a major feature. We would still spec explicit solutions for heavy CPU tasks, etc.

I just question if maintaining one-and-only-one instance is worth the code complexity and perf hit in a multi-process browser architecture.

Can chrome produce multiple service worker instances for different content processes today? Or do you already have a solution to guarantee a single SW instance across the entire browser?

mkruisselbrink commented 8 years ago

Chrome already guarantees a single SW instance across the entire browser. Spinning up multiple SW instances does seem like something worth exploring though. I do think we'll have to clarify how various things that return a ServiceWorker instance deal with this, and what happens if you postMessage to them.

In particular this seems like it could be helpful in cases of misbehaving service workers (where we can then fire up a new SW and start sending fetch events there, without having to wait for some timeout blocking all other fetches), or just in general in situations where there is a performance concern (foreign fetch comes to mind as well; having potentially every website you're browsing to try to fetch something from the same SW could result in noticeable slowness).

jakearchibald commented 8 years ago

We've discussed this before a few times. I've always been pretty keen on it, but if memory serves @slightlyoff was less keen.

I'm thinking of http2-optimised pages made up of 100s of requests, is there a performance benefit to multiple workers?

delapuente commented 8 years ago

IMHO, this is an implementation detail low enough to not live at the spec level. In the spec we should specify some warranties, for example saying that logically there is only one service worker per scope although it could be implemented as several ones.

wanderview commented 8 years ago

It has to be in the spec becauae it's observable with the right combination of waitUntil(), postMessage(), and worker global variables.

delapuente commented 8 years ago

By sending global state via postMessage() or respondWith()? If so, well, I suppose it could be a recommendation.

wanderview commented 8 years ago

You use set some global state in the SW, then use an evt.waitUntil() to hold the SW alive, and then send a postMessage() or other event triggering mechanism that checks the global state.

jakearchibald commented 8 years ago

Some thoughts on our options here:

  1. Let the browser create concurrent instances of the service worker whenever it wants
  2. Allow particular events to opt into or out of concurrency, so fetch events could be split across many instances but message events may not
  3. Make concurrency an opt in/out feature by the developer

I'd prefer the first, but the others are ways out if it breaks the web in ways we can't work around.

mkruisselbrink commented 8 years ago

I agree that 1 would be the preferred outcome.

Another variant of 2 could be to make concurrency a per-client thing. So all message/fetch events related to the same client would always end up in the same worker, but other events can end up anywhere. That might have the lowest risk of breaking things, would probably address the browser-vendor implementation concerns, but obviously wouldn't allow some of the optimisations that full concurrency could, as it might very well be beneficial to process multiple fetch events from the same client in multiple threads.

wanderview commented 8 years ago

From an implementor's point of view (1) is very attractive. It gives us the most flexibility to optimize with the smallest amount of complexity. Options (2) and (3) restrict us more and add more complexity to support unique worker instances across processes.

jakearchibald commented 8 years ago

all message/fetch events related to the same client would always end up in the same worker, but other events can end up anywhere … wouldn't allow some of the optimisations that full concurrency could

Agreed, that's what lead me to 2. That way you would have concurrency with fetch, but not with message/push. We'll see what users think. We don't need to consider 2/3 unless 1 is unattainable.

wanderview commented 8 years ago

If we did multiple SW instances I think we could maybe still support one-to-one messaging with the instances. For example, when a SW receives a fetch event it could construct a new MessageChannel. It could then postMessage() the channel back to the client. The client could then use the channel to communicate with that single instance directly.

I think that would work, but maybe I don't fully understand MessageChannel.

wanderview commented 8 years ago

I also have some local patches that spin up N thread instances for each service worker. Let me know if there is a particular site that I should test.

jakearchibald commented 8 years ago

https://www.flipkart.com/ might be an interesting one to try.

Yeah your MessageChannel idea would work.

mkruisselbrink commented 8 years ago

Having the SW send the MessageChannel to the client in its onfetch event works, provided that:

  1. We implement some way to expose the to-be-created client in the fetch event for a navigation (I'm not sure if we were planning to just have a client id for this new client, or if there would be an actual Client the SW can lookup via Clients.get for this?), and
  2. The same queueing of messages posted to clients returned by openWindow is also applied to messages posted to these clients. I think this should just work as well (at least as specced).
jakearchibald commented 8 years ago

Yeah, that's the plan. clients.get(fetchEvent.reservedClientId) would get you a client and posted messages would be buffered.

jakearchibald commented 8 years ago

Some more thoughts:

Postmessaging to a SW will land in one instance, whereas SharedWorker and BroadcastChannel will expose multiple running instances. Probably not an issue, but wanted to write it down while I remembered.

wanderview commented 8 years ago

In theory, ServiceWorker.postMessage() could spawn a new instance each time. Right?

jakearchibald commented 8 years ago

Yeah, whereas BroadcastChannel could land in multiple instances at once.

aliams commented 8 years ago

Just wanted to chime in that I also support option 1 as well.

arcturus commented 8 years ago

Just leaving some feedback related to:

https://jakearchibald.com/2016/service-worker-meeting-notes/

Are you maintaining global state within the service worker?
Would this change break your stuff?
If so, could you use a more persistent storage like shared workers / IndexedDB instead?

So far we have been already using indexedDB (with localForage for simple key/value) the fact that the SW can be killed was strong enough to not to keep states on memory and persist them.

wheresrhys commented 8 years ago

Re: the PR above. We were using persistent state stored in a variable, but have been able to switch to indexedDB. In time it'd be nice to have a higher level API for syncing shared state between sw instances as polling an indexedDB store is a bit hacky, and accessing indexedDB on every fetch event wouldn't be particularly performant. Similarly, I'd love to see a cookies API land in the near future - we're considering implementing something similar to what we've done for feature flags for cookies in the meantime

bkimmel commented 8 years ago

I use Clients.claim a lot just to choke down the simplicity of the SW life cycle (which for me has always been the most complicated aspect of dealing with Service Workers). How would that work with a litany of SWs claiming the client in rapid succession?

bkimmel commented 8 years ago

Compared to the folks on the list that are working on this, I think I would have a hard time pouring water out of a bucket with instructions on the bottom. So feel free to take this with a giant grain of salt... but what if there were a new kind of Worker ( a FetchWorker ) that could be provided as an argument to onfetch. Like self.on('fetch', new FetchWorker('fetch.js' )). That way you could parallelize the hell out of that FetchWorker without making everyone manage state in SWs with IndexedDB... which (speaking on behalf of the most obtuse 5% of SW users) sounds about as much fun as being a rattlesnake in a room full of rocking chairs. It would also cleave nicely with the "Progressive Enhancement" philosophy of ServiceWorkers.

wanderview commented 8 years ago

I use Clients.claim a lot just to choke down the simplicity of the SW life cycle (which for me has always been the most complicated aspect of dealing with Service Workers). How would that work with a litany of SWs claiming the client in rapid succession?

We would still only allow one instance of the SW to run the install and activate events at a single time. Updates would be single threaded. I mention this because thats when most SW's call clients.claim().

If you call clients.claim() on every FetchEvent, though, I think it would still work. It would be about the same amount of work whether we have a single instance or multiple instances. You have to look across all processes at all windows to potentially mark them as controlled. That workload does not really change with more running service worker instances.

If you are doing heavy js or clients.claim() at the top level script evaluation, though, then you would likely see a performance hit from spinning up more thread instances.

bkimmel commented 8 years ago

@wanderview Thank you for taking the time to explain that. Makes more sense now.

Follow-up question: I've used patterns in the past of "hanging" a service worker mid-lifecycle with event.waitUntil until "things" happen (some record appears in IndexedDB, a message from the client... see https://github.com/bkimmel/bkimmel.github.io/blob/master/serviceworker_bgs/sw.js ). It seems like if there were multiple SWs running, those wouldn't really be "usable" patterns anymore, right? Not a huge deal: I don't have anything in production doing that, but I guess this concurrent stuff would mean I'd have to think about those sort of creative applications a little bit differently, right?

Edit: Also: How would MessageChannel ports work in a concurrent situation? I like to use MessageChannels to communicate with SWs from the client and I'm having trouble understanding how that would work once I dispatch the message from the window and give port2 to the SW... would the concurrent workers all "share" the port2 I sent to the SW?

ju-lien commented 8 years ago

Several thoughts on allowing multiple SW instances.

wanderview commented 8 years ago

Follow-up question: I've used patterns in the past of "hanging" a service worker mid-lifecycle with event.waitUntil until "things" happen (some record appears in IndexedDB, a message from the client... see https://github.com/bkimmel/bkimmel.github.io/blob/master/serviceworker_bgs/sw.js ). It seems like if there were multiple SWs running, those wouldn't really be "usable" patterns anymore, right? Not a huge deal: I don't have anything in production doing that, but I guess this concurrent stuff would mean I'd have to think about those sort of creative applications a little bit differently, right?

I believe you can use IDB transactions to achieve this.

Edit: Also: How would MessageChannel ports work in a concurrent situation? I like to use MessageChannels to communicate with SWs from the client and I'm having trouble understanding how that would work once I dispatch the message from the window and give port2 to the SW... would the concurrent workers all "share" the port2 I sent to the SW?

When you use navigator.serviceWorker.postMessage() you would only see the message event in a single SW thread instance. You would not be able to control which instance receives the postMessage.

If you want to communicate with all SW thread instances you could use BroadcastChannel.

If you want to communicate with a particular thread instance you would need to have the SW thread create the MessageChannel first and send it back to the window Client. The SW thread could then keep itself alive with waitUntil() until the window messages back, etc.

wanderview commented 8 years ago

Result: 2 requests to the server, 2 caching operations ? Or i miss something ?

Yes, you could have 2 requests to the server. But this is no different than with a single thread handling two overlapping FetchEvents.

This open the door to create a process for each fetch request ?

It would leave it up to the browser to decide where to dispatch the FetchEvent. It would be allowed to spawn a new process for each one, but I think its highly unlikely any browser would actually implement it that way. We would be trying to focus on running it where the FetchEvent would have the quickest response time.

As said by wheresrhys, call indexedDB to store globally a state on each fetch or postmessage event can become a pb for performances ?

The question is, are people relying heavily on global state today given that the service worker can be killed whenever you are out of the waitUntil() anyway? If you are doing anything with global state outside of waitUntil then you need to use IDB even with a single SW thread.

We're trying to understand what the use cases for global state across events within a waitUntil() are today. Without seeing the use cases its hard to know if IDB is too heavy-weight.

adamvy-google commented 8 years ago

We would like to use global state for service workers. In my ideal world a service worker would be alive as long as and page accessing it is alive.

In developing mail clients, chat clients, and any app that has a more than trivial data layer, it's ideal to have a single source of truth for the data layer of the application.

Typically we want to store data in IDB, and layer business logic and in memory caching on top of that. Before service workers we would use a SharedWorker for this, a single destination that services data requests from all the UI components.

In trying to add background sync with service workers, this model no longer works because a ServiceWorker cannot open and keep alive a SharedWorker. Meaning the ServiceWorker has to duplicate the business logic of the SharedWorker, in a thread-safe manner. The odds of any developer (let alone the average javascript developer) getting this right and not introducing data consistency bugs is incredibly low.

I think a better model for ServiceWorkers would be to behave more like SharedWorkers. Live in the background as long as any page is open, or background sync is in progress. If anything it is less resource intensive to keep the ServiceWorker alive while my UI is open, rather than have to spin it up repeatedly every time I make a request. It also means that the ServiceWorker can be used to cache indexeddb data in memory, reducing the I/O load and increasing performance.

wanderview commented 8 years ago

ServiceWorker cannot open and keep alive a SharedWorker

I think we would like to fix this. You should be able to create/access SharedWorkers from a ServiceWorker. Would this address your particular use case?

adamvy-google commented 8 years ago

I think so, but I'd have to try it to be certain. It sounds promising, assuming I can keep the SharedWorker alive long enough when a background sync event has triggered. It has to do a fair amount of async IDB work, but there's no setTimeouts involved.

adamvy-google commented 8 years ago

Is there a specific bug I can follow about SharedWorkers so I don't derail this issue discussion any further?

That being said, I think the SharedWorker lifecycle would be better suited to the ServiceWorker use case than the current lifecycle.

In order to provide strong offline support, the ServiceWorker has to emulate my existing backend server, but also be lightweight enough to spin up in a few milliseconds to respond to fetch requests.

Those seem like two competing goals. A heavier weight ServiceWorker that can live as long as it is likely to receive events ( as long as UI components are open, and when background sync in imminent ), seems like it would be more suited to its task of providing robust offline support.

wanderview commented 8 years ago

I think so, but I'd have to try it to be certain. It sounds promising, assuming I can keep the SharedWorker alive long enough when a background sync event has triggered. It has to do a fair amount of async IDB work, but there's no setTimeouts involved.

You would use a waitUntil() to hold the ServiceWorker alive while the SharedWorker is doing its work. This should work for most workloads. In theory you could trigger the hard kill if you try to keep it alive for many minutes in the background, though.

For other SharedWorker issues, see:

https://github.com/slightlyoff/ServiceWorker/issues/678 https://github.com/whatwg/html/issues/411

wanderview commented 8 years ago

I uploaded some custom firefox builds that spawn a new worker thread for every event fired at the service worker:

https://people.mozilla.org/~bkelly/sw-builds/hydra/

I'll write a blog post explaining so people can test for themselves.

Note: These builds are for compat testing only and shouldn't be used to measure perf differences. Its a pretty dumb algorithm and designed just to stress test a new global for every event. The builds also kill workers off much more aggressively to avoid piling up a ton of workers. So it will show where people are not using waitUntil, etc.

https://www.flipkart.com/ might be an interesting one to try.

I did test this one and it seems to have issues. It works in the normal online case, but never seems to fully load when offline.

bsittler commented 8 years ago

@wheresrhys re: cookies API, would https://github.com/bsittler/async-cookies-api come anywhere close to meeting those needs? It's still a proposal in its early stages at this point, so even an optimistic timeline for it is likely nowhere near when you'd like it, but I'm certainly interested in any feedback you have on its suitability (or lack thereof) and any features you would like to see

jakearchibald commented 8 years ago

@wheresrhys can you go into detail for why you need to poll IDB? IDB observers will remove the need for polling, but I'm curious about your use-case.

jakearchibald commented 8 years ago

@bkimmel

I use Clients.claim a lot just to choke down the simplicity of the SW life cycle

Can you explain when & why you're calling clients.claim()? I don't find the need to use it all that often vs skipWaiting(). Btw I explained the lifecycle over at https://www.youtube.com/watch?v=TF4AB75PyIc.

jakearchibald commented 8 years ago

@bkimmel

but what if there were a new kind of Worker ( a FetchWorker ) that could be provided as an argument to onfetch. Like self.on('fetch', new FetchWorker('fetch.js' )). That way you could parallelize the hell out of that FetchWorker without making everyone manage state in SWs with IndexedDB

How would this be different to option 2 of https://github.com/slightlyoff/ServiceWorker/issues/756#issuecomment-236948511? (except option 2 doesn't need extra API)

The SW would still be killed when it isn't used though, so global state would still be unreliable.

jakearchibald commented 8 years ago

@bkimmel

I've used patterns in the past of "hanging" a service worker mid-lifecycle with event.waitUntil

event.waitUntil doesn't prevent events of the same type firing. Eg, it doesn't hold up other fetch events.

I get that there's a fear of concurrency here, but do you have an example of something you're doing now that would break with concurrency?

jakearchibald commented 8 years ago

@ju-lien

It's remove the meaning of "this.addEventListener('fetch'", what will be the meaning of "this." ? A random SW scope ? So it could be a good thing to change this syntax to register handler and avoid confusions.

I'm not sure I understand this. this will be the global scope of the worker. Due to how the service worker shuts down when it isn't in use, the value of this in terms of JS equality may not be the same for two fetches. So if you do this.foo = 'bar' in one fetch, it may not be there in another fetch. Parallel workers makes this even less consistent between events, but it's already inconsistent.

jakearchibald commented 8 years ago

@adamvy

We would like to use global state for service workers. In my ideal world a service worker would be alive as long as and page accessing it is alive.

In developing mail clients, chat clients, and any app that has a more than trivial data layer, it's ideal to have a single source of truth for the data layer of the application.

I think what you want already exists - SharedWorker. This is designed to stay alive while clients have a reference to it, whereas service worker terminates to save resources. Like @wanderview says, we need to tweak the spec of SharedWorker to allow them to exist in service workers.

bkimmel commented 8 years ago

@jakearchibald

Can you explain when & why you're calling clients.claim()?

The when is pretty much every SW I've authored since I learned you could call clients.claim(). The why is to stop myself from quietly sobbing in shame for not being able to control SW lifecycles the way I want to when I'm in development. It's just a thick callous I've developed over one of the friction-points in SW development. I think I picked it up from an article a while back and it really seems to have made things easier for me in dev. Thanks for that video link - I try to keep myself up-to-date on all things Jake Archibald and I missed that somehow.

How would this be different to option 2 of #756 (comment)? (except option 2 doesn't need extra API)

The SW would still be killed when it isn't used though, so global state would still be unreliable.

Maybe it wouldn't be different than #2, but I was under the assumption that all of that was under the umbrella of the "whole SW is concurrent". I have come to enjoy the fact that SW global state is ephemeral, because it enforces some good practices... but I still see a marked difference between "There is 1 of these in active state and it might die at any time" and "There are an unpredictable number of these things active and they die". The latter is harder for me to reason about. I am a renowned idiot so that could have a lot to do with why it would it's difficult for me to grasp.

There are some other really cool things I could think of to do with an API like that (a way to share some stuff more easily with the client)... but I digress; maybe with your #2, we are basically talking about the same approach.

I get that there's a fear of concurrency here, but do you have an example of something you're doing now that would break with concurrency?

https://github.com/bkimmel/bkimmel.github.io/blob/master/serviceworker_bgs/sw.js The most fun example I can think of: This dark pattern where I abuse sync / event.waitUntil to violate the user's privacy by notifying myself the next time they turn their browser on with no consent on their part. (I sent this to you a few months back on Twitter. I proved it in practice a couple of versions of Chrome ago, haven't tested it since.) This would still work, but would it maybe send 20x messages home for 20x concurrent SWs? Or can you make it so that just one SW gets the sync and the other ones you spun up don't?

But more generally, any pattern where I wait in the SW for a message from the client... since as it was pointed out above, now I have absolutely no idea which evil SW twin will get the message to proceed. Ben helpfully pointed out BroadcastChannel, but last I checked it wasn't in Chrome yet. The short story is I don't have anything in production that does this and I think I could eventually adapt my way of thinking to accomodate.

Overall, I love the work you guys do and if you say "calm down, it'll be OK" then I'll snap to. I'm just baring my ignorance here for the potential benefit of anyone else who is riding the "special bus" with me and has difficulty thinking about how this would work.

jakearchibald commented 8 years ago

@bkimmel

The when is pretty much every SW I've authored since I learned you could call clients.claim(). The why is to stop myself from quietly sobbing in shame for not being able to control SW lifecycles the way I want to

It sounds like (and there's no shame in this) you're not really sure why you call it or what it does, beyond "it seems to help". Is that fair to say? Which way do you want to control the lifecycle. As in, what would the lifecycle be if you had full control?

would it maybe send 20x messages home for 20x concurrent SWs? Or can you make it so that just one SW gets the sync and the other ones you spun up don't?

No, no global event would be duplicated. If you're getting one event for it today, you'll still get one event for it in this concurrent model. Currently, if 50 distinct fetch events need to be fired, they'll happen in the same SW instance unless the SW is terminated in between. In the concurrent model, the 50 fetch events will be load-balanced across multiple instances, but the total number of fetch events will still be 50.

But more generally, any pattern where I wait in the SW for a message from the client... since as it was pointed out above, now I have absolutely no idea which evil SW twin will get the message to proceed

We're looking for cases where that would matter. It should only matter if you're holding state in the global scope, which is already unreliable but will become more unreliable. Are you doing that?

wheresrhys commented 8 years ago

@jakearchibald The use case is changing the sw behaviour without having to release a new version. We have a feature flag system which enables us to roll out any new feature of the site behind a flag, and QA it in production by means of setting a cookie, which then sets headers, template state etc, propagating through the stack for that user's requests only. Having this available for QAing service workers is useful too, so on page load a message is sent to the SW to tell it which flags are on, and caching & retrieval of responses within the sw is conditional on the state of the flag.

If there was guaranteed to be one sw that didn't die then the posted message would be enough. As that isn't the case, storing the flag state in IDB to be retrieved on startup of the worker is necessary. Then, when handling fetches, it would be possible to read from IDB for each fetch request, but that seems like a performance nightmare - I don't know much about IDB perf, but I can't imagine calling it for most fetch requests would be good. Polling IDB to pick up any flag updates that may have been posted to another sw instance, so that the flag state can be read from a local variable, is a good enough compromise.

jakearchibald commented 8 years ago

@wheresrhys polling doesn't sound like great performance in this case. IDB observers will help. But, do your flags often change during the life of a service worker? If not, cache the value in a promise in the global scope. If they change often, request them per fetch - don't assume it'll be slow until you see it for yourself (we say "tools not rules", meaning evidence trumps fear).

NekR commented 8 years ago

But, do your flags often change during the life of a service worker?

I think what that doesn't matter since the intention is to get current flags, not to check if they updated. I also guess that IDB Observers won't help because they won't be triggered on each SW startup and it actually will be no different than a pulling on SW startup.

As I understand it right, navigate pages aren't cached by SW or are network-first. SW doesn't have any flags in code, which is why every navigate page have to send a message to the SW to store flags. Even on those flags don't change (but I guess they may, e.g. different flags for different users), SW still have to read those flags from IDB because it could be killed after last fetch and for new fetch it needs flags again.

This is how I understand it.

jakearchibald commented 8 years ago

@NekR

As I understand it right, navigate pages aren't cached by SW

Are we talking about SW in general or a particular site using SW? The only thing cached automatically by the SW is the SW script and its importScripts. No page fetches, navigation or otherwise, are cached by the service worker unless you write code to do so.