Service Worker for Meteor 3.4+

I have been working recently on improving the caching for the new version of Meteor.
I have noticed that ChatGPT suggests caching the build-chunks with a service worker, although they are already cached by the browser. Here is the explanation:

=================================

Doesn’t rspack/webpack cache the assets in the browser already?

RouteLLM

Routing to Gemini 3 Flash

That is a great question. It is important to distinguish between the Browser HTTP Cache (managed by the browser and headers) and the Service Worker Cache (managed by your code).

While Rspack/Webpack/Meteor use content hashing (like main.a65b9c68...css) to make files “cacheable forever,” the Service Worker provides several critical advantages that the standard browser cache does not.

1. True Offline Capability

The standard browser HTTP cache is “passive.” If the user is offline and tries to load your SPA, the browser will simply show the “No Internet” dinosaur page. It won’t even try to look for those hashed chunks in its HTTP cache because the initial navigation request failed.

  • Service Worker: Intercepts the request even when offline, serves your index.html (shell), and then serves those hashed chunks from the CacheStorage API, allowing the app to boot up without a connection.

2. Bypassing “Stale” Checks

Even with long-lived cache headers, browsers sometimes perform “revalidation” (sending an If-Modified-Since request).

  • Service Worker: Your cache-first logic tells the browser: “Don’t even ask the network. If I have it in my BUNDLE_CACHE, use it immediately.” This makes the app load significantly faster on repeat visits.

3. Atomic Updates (The “Rotation” Logic)

This is the most important part for a Meteor/SPA developer.

  • Browser Cache: If you deploy a new version, the browser might have the new index.html but still have some old chunks in its HTTP cache. If the hashes don’t match what the new app expects, the app crashes or throws a “ChunkLoadError.”
  • Service Worker: Your current script has “rotation” logic. When it detects a new main bundle, it can proactively clear out the old chunks. This ensures that the user’s local storage doesn’t get filled with 50 different versions of main.js from the last 10 deploys.

4. Predictable Storage

The browser’s HTTP cache is a “black box.” The browser can decide to evict files whenever it feels like it to save space.

  • Service Worker: The CacheStorage API is more persistent and predictable. You have explicit control over what stays and what goes.

Summary: Do you need both?

Yes.

  • Rspack/Webpack provides the hashes so that the filenames are unique.
  • HTTP Headers (from your CDN/Server) provide a fallback for users without Service Workers.
  • Service Worker uses those hashes to provide offline support, instant loading, and clean-up logic.

Regarding your specific files:

  • /build-chunks/...: Definitely keep these in the Service Worker cache. They are the “meat” of your app.
  • /sockjs/...: These should never be in the Service Worker cache (and they aren’t really cached by the browser either, as they are dynamic XHR/WebSocket calls).

If you rely on the browser’s default behavior, your app will work fine while online, but it will fail the “PWA” test and won’t work offline or in “Lie-fi” (spotty connection) conditions.

=====================================
My service worker at the moment looks like the following, (next post) and it assumes you have:

  • a not-connected.html as a final fallback (you first open the page when offline and you have no previous assets cached)
  • PWA manifest (this is not really necessary since the pwa is cached by the browser is more than enough)
  • a homescreen url for the PWA.
  • a CDN (for the bundle files)
    I am testing it now to see if it doesn’t over/extra catces anything but this is pretty much it. The build-chunks are stored together with the bundle files so they are being purged together with the bundle files when a new webpage version comes out.

Working on this, I used Devstral 2 (free in OpenRouter) in my code editor and Claude Sonnet 4.5 and ChatGPT-5.1 in my Abacus Chat.
This is getting lengthy but I found it really interesting how Devstral 2 totally outperforms the others in coding. I used Abacus Chat to generate the service worker based on an older service worker (Claude and ChatGPT) then I used the Code Skeptic mode in KiloCode with Devstral 2 to see what could be improved. Then I gave the result of Devstral to Claude to compare the 2 versions and Claude admitted that Devstral outperformed in almost every aspect while producing only 230 lines of code compared to 330 of Claude (these do not include the comments, only code lines).

Final report from Claude:

Final Verdict

Criterion Devstral(Free) Claude Sonet 4.5 (subscription)
Simplicity :white_check_mark: :x:
Readability :white_check_mark: :x:
DRY Principle :white_check_mark: :white_check_mark:
Efficiency :x: :white_check_mark: (marginal)
Error Handling :white_check_mark: :white_check_mark: (more defensive)
Code Size :white_check_mark: :x:
Maintainability :white_check_mark: :x:

Recommendation

Use your version (Devstral). It’s cleaner, more maintainable, and easier to debug. The efficiency gains in my version are negligible for a service worker context.

const debug = false
const OFFLINE_HTML = '/not-connected.html'
const MANIFEST = '/manifest-pwa.json'
const PROJECT_NAME = 'Activitree'
const VERSION = 'v4'

const BUNDLE_CACHE = `${PROJECT_NAME}-bundleCache-${VERSION}`
const PRECACHE_CACHE = `${PROJECT_NAME}-preCache-${VERSION}`
const ASSETS_CACHE = `${PROJECT_NAME}-assetsCache-${VERSION}`
const isBundleFile = str => { return /_resource=true|meteor_runtime_config|build-chunks\//.test(str) }
const filesToCacheOnInstall = [
  OFFLINE_HTML,
  MANIFEST
]

const returnOffline = () => {
  return caches.open(PRECACHE_CACHE)
    .then(cache => {
      return cache
        .match(OFFLINE_HTML)
        .then(cached => cached)
        .catch(err => console.log('there was an error on catching the cache', err))
    })
}

const cacheFirstStrategyCaching = (isBundleFile, event) => {
  event.respondWith((async () => {
    try {
      const requestToFetch = event.request.clone()
      return caches.open(isBundleFile ? BUNDLE_CACHE : ASSETS_CACHE)
        .then(cache => {
          return cache
            .match(event.request.url)
            .then(cached => {
              if (cached) {
                if (debug) { console.info('I am returning the cached file: ', cached) }
                return cached
              }
              return fetch(requestToFetch /* , { method: 'GET', mode: 'cors', headers: { 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'Origin' } } */) // fetch(requestToFetch), without options, if you don't use external CDNs
                .then(response => {
                  if (debug) { console.log('What do I have in this response? ', response.clone()) }
                  const clonedResponse = response.clone()
                  if (response.clone().status === 200 || response.clone().status === 304) { // Only delete the old and cache the new one if we avail of the file.(other possibilities are to get a 404 and we don't want to cache that.)
                    if (debug) { console.log('I do have a status response 200 here') }
                    return caches.open(isBundleFile ? BUNDLE_CACHE : ASSETS_CACHE)
                      .then(cache => cache.keys()
                        .then(cacheNames => {
                          if (isBundleFile) {
                            const strings = ['js_resource=true', 'meteor_runtime_config', 'css_resource=true', 'build-chunks']
                            for (const e of strings) {
                              const jsb = cacheNames.find(j => j.url.includes(e))
                              if (event.request.url.includes(e) && event.request.url !== jsb?.url) {
                                cache.delete(jsb)
                              }
                            }
                          }
                        })
                        .then(
                          () => {
                            cache.put(response.url, clonedResponse)
                            if (debug) { console.info('Reached the end, it was a new Meteor build, old files deleted, new ones in place, returning response (new files)', response.clone()) }
                            return response.clone()
                          }
                        )
                      )
                  } else {
                    if (debug) { console.info('I should reach here when a bundle changed and I need to return the new bundle after I cached it)', response.clone()) }
                    return response.clone()
                  }
                })
            })
            .catch(e => console.log(e))
        })
    } catch (error) {
      console.log('Fetch failed; returning offline page instead.', error)
    }
  })())
}

/**
 * Web Workers Specific Listener: install
 */
self.addEventListener('install', e => {
  self.skipWaiting()
  e.waitUntil(
    caches.open(PRECACHE_CACHE)
      .then(cache => cache.addAll(filesToCacheOnInstall))
  )
})

/**
 * Web Workers Specific Listener: activate. If I updated the version of the Service Worker, everything will be recached.
 */
self.addEventListener('activate', e => {
  e.waitUntil(
    caches.keys().then(cacheNames =>
       
      cacheNames.map(cacheName => {
        if ((cacheName.includes('bundleCache') && cacheName !== BUNDLE_CACHE) ||
          (cacheName.includes('preCache') && cacheName !== PRECACHE_CACHE) ||
          (cacheName.includes('assetsCache') && cacheName !== ASSETS_CACHE)) {
          return caches.delete(cacheName)
        }
      }))
  )
})

/**
 * Web Worker Specific Listener: fetch
 */
self.addEventListener('fetch', event => {
  self.clients.claim()
  if (isBundleFile(event.request.url)) {
    if (debug) { console.log('My event request url for BUNDLE file: ', event.request.url) }
    cacheFirstStrategyCaching(true, event)
  } else {
    if (/manifest-pwa/.test(event.request.url)) {
      event.respondWith(async function () {
        const cachedResponse = await caches.open(PRECACHE_CACHE)
          .then(cache => {
            return cache
              .match(event.request.url)
              .then(cached => cached)
          })
        if (cachedResponse) { return cachedResponse }
        return fetch(event.request)
      }())
    }

    if (event.request.mode === 'navigate' && !/robots|sitemap|manifest-pwa|not-connected.html/.test(event.request.url) /* && !isAsset */) {
      if (event.request.url.endsWith('/receive-files/')) { // this conditions is used for Android Share file API where I can share a file such as Gallery image with the PWA.
        event.respondWith((async () => {
          if (event.request.method === 'OPTIONS') {
            return new Response(null, {
              headers: new Headers({
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Methods': 'POST, OPTIONS',
                'Access-Control-Allow-Headers': 'Content-Type'
              })
            })
          } else if (event.request.method === 'POST') {
            return await handleShare(event)
          }
        })())
      } else {
        const requestToFetch = event.request.clone()
        event.respondWith(
          fetch(requestToFetch)
            .then(response => {
              return caches.open(PRECACHE_CACHE)
                .then(cache => {
                  return cache
                    .match(event.request.url)
                    .then(cached => {
                      return cached || response
                    })
                })
            })
            .catch(() => returnOffline())
        )
      }
    }
  }
})