I know this one stings. You shipped a PWA that worked perfectly on Chrome, Edge, and every Android browser. Then someone on macOS Sonoma opens it in Safari, goes offline, and gets a blank screen. Safari 17.1 shipped in October 2023 with a change to how service workers handle fetch events, and it quietly nuked offline functionality for a lot of PWAs. The DevTools console gives you nothing useful — just TypeError: Load failed or a silent failure with no network request logged at all.
I spent two days of my life on this last November. Here's what's actually happening and how to fix it.
Cause 1: Your fetch handler uses async/await without responding to the event synchronously
This is the one that bites almost everyone. Safari 17.1 tightened up how it handles fetch events in service workers. If your handler looks like this, it's probably broken:
self.addEventListener('fetch', async (event) => {
const cached = await caches.match(event.request);
if (cached) return cached;
return fetch(event.request);
});
On Chrome this works. On Safari 17.1, the event can complete before your async work resolves, and Safari drops the response. That's why you see a blank page offline instead of your cached shell.
The fix is to call event.respondWith() synchronously, then do your async work inside:
self.addEventListener('fetch', (event) => {
event.respondWith((async () => {
const cached = await caches.match(event.request);
if (cached) return cached;
try {
return await fetch(event.request);
} catch (e) {
return caches.match('/offline.html');
}
})());
});
Notice the handler itself is no longer async. The outer function runs synchronously, respondWith gets called before the event lifecycle ends, and the promise resolves later. That's the pattern Safari 17.1 wants. If you take nothing else from this article, take this — it's the fix for roughly 70% of the reports I've seen.
Cause 2: You're caching responses with mismatched Vary headers or opaque responses
Safari 17.1 also changed how it treats opaque responses (those from cross-origin requests without CORS) in the Cache API. If you're caching CDN assets, fonts from Google, or analytics scripts without proper CORS headers, Safari may serve a corrupted or empty response offline even though the cache entry exists.
You'll spot this by checking Application → Cache Storage in Safari's Web Inspector. The entry is there. The size looks right. But await cached.text() returns an empty string.
Two options. First, add crossorigin="anonymous" to your cross-origin <script> and <link> tags and make sure the server returns proper Access-Control-Allow-Origin headers. Then you get real responses, not opaque ones.
Second, if you can't control the origin, skip caching opaque responses entirely:
caches.open('v1').then((cache) => {
cache.addAll([
'/',
'/index.html',
'/app.js',
'/styles.css'
// don't add third-party URLs here
]);
});
Let those requests hit the network. Your app still works offline for the shell and core assets, which is what matters. I know it feels wrong to give up on caching fonts, but a broken cache is worse than no cache.
Cause 3: Service worker registered with a scope mismatch after the 17.1 update
Less common but nasty. If your PWA was installed before the Safari 17.1 update and your service worker scope was something like /app/, Safari 17.1 may fail to match requests against the existing registration. You'll see fetch events fire in DevTools but with an empty event.request.url, or the handler never fires at all for in-scope URLs.
The annoying part: reinstalling the PWA doesn't always help because Safari caches the registration. Force it by bumping your service worker file:
- Change one byte in your
sw.jsfile (add a comment). - In Safari, go to
Develop → Service Workersand click Unregister for your origin. - Quit Safari completely —
Cmd+Q, not just closing the window. - Delete the installed PWA from
/Applicationsif it's a macOS Sonoma web app. - Reinstall from Safari, reopen, and check the console.
After that, verify the scope in the console:
navigator.serviceWorker.getRegistration().then(r => console.log(r.scope));
It should match the path where your WPA is installed. If it says https://yoursite.com/ and your app lives at https://yoursite.com/app/, register with an explicit scope:
navigator.serviceWorker.register('/sw.js', { scope: '/app/' });
Wait — is downgrading Safari an option?
No. Safari ships with the OS and you can't roll it back without reverting all of macOS Sonoma. Don't waste an evening trying. The three fixes above work, and once you patch the fetch handler pattern in Cause 1, you're probably done. The other two causes only matter if you're still seeing failures after that.
Safari's release notes for 17.1 mention "improved service worker reliability" — which is corporate-speak for "we changed the spec-compliance details and didn't tell anyone." The WebKit bug tracker has a few threads on this, but Apple hasn't shipped a fix as of 17.3.
Quick reference
| Symptom | Cause | Fix |
|---|---|---|
| Offline page blank, fetch event never fires or resolves empty | Async fetch handler | Call event.respondWith() synchronously, do async work inside the promise |
| Cache entry exists but response is empty offline | Opaque responses / Vary headers | Add crossorigin="anonymous" or stop caching cross-origin assets |
| Fetch handler fires with empty request URL, or not at all for in-scope routes | Scope mismatch after 17.1 update | Unregister SW, hard-quit Safari, reinstall PWA, set explicit scope on register() |
Start with the fetch handler fix. It takes five minutes and it's almost certainly your problem. If you're still stuck after that, check the Cache Storage panel in Web Inspector and look at what's actually stored — that'll tell you fast whether you're in Cause 2 territory.