Skip to main content
Development

When 'Just Refresh' Doesn't Work: Taming PWA Cache Behavior

When Safari's aggressive caching turned our Progressive Web App into a prison for stale content, we embarked on a two-month journey to fix it. Here's what we learned about service workers, cache invalidation, and why 'just refresh the page' doesn't always work.

Eric Wagoner
12/16/2025
10 min read

The story starts with a QA report: "I logged out and logged back in as a different user, but I'm still seeing the old user's data." What followed was a two-month journey through one of the most frustrating challenges in modern web development: fighting aggressive browser caching in a Progressive Web App.

This is the story of how our team wrestled with Safari's PWA caching behavior, the creative solutions we developed, and the hard-won lessons that might save you from the same headaches.

The Promise and the Problem

We're not new to mobile development. Our team has built dozens of mobile apps for native iOS, Android, and cross-platform solutions. We've shipped countless web applications optimized for mobile. But this fall we had our first Progressive Web App (PWA) project, and we were about to learn that PWAs come with their own unique set of challenges.

The project was a proof of concept for our client: a focused application with just enough features to be useful so they could test the market and validate whether a larger investment made sense. If the concept proved out, native iOS and Android apps with a broader feature set might follow.

A PWA built with Flutter was the strategic choice. Flutter would let us build a PWA now, avoiding the complexity of App Store submissions and TestFlight beta management during the proof-of-concept phase. Should the client later want true native apps, we could leverage much of the same codebase to target iOS and Android directly. It was a pragmatic path: validate first, quickly, then scale.

The development went smoothly. The app looked great, performed well, and our client was thrilled with the progress. We rapidly iterated with weekly releases, collecting feedback and catching issues early through internal QA and client UAT sessions. That process was about to pay off in ways we didn't expect.

When "Refresh" Doesn't Mean Refresh

As our QA team was testing on Safari, a pattern of issue reports started to come in. After logging out and logging back in to the PWA as a different user, they were seeing the previous user's data. This was a serious privacy and usability concern that we were glad to catch before release. This is why we QA, right?

Our initial fix was straightforward: clear the in-memory cache when a user logs in. Problem solved, we thought.

But Safari had other plans.

A few weeks and several release cycles later, more QA reports came in. We deployed new builds, but testers couldn't see updates. Our brute force fallbacks we used for typical websites, hard refresh and closing and reopening Safari, didn't work. Even restarting the browser entirely didn't help. Our QA team and UAT users were stuck on old versions of the app, unable to access new features or bug fixes we'd just deployed.

The kicker? iOS Safari worked fine. The problem was specifically macOS Safari: the same browser, different caching behavior.

Understanding the Beast

To understand what we were fighting, you need to understand how PWAs cache content. When a user visits a PWA, a service worker (essentially a script that runs in the background) intercepts network requests and can serve cached responses instead of going to the server. This is fantastic for performance and offline capability, but it creates a fundamental tension: how do you update an app when the thing responsible for fetching updates is itself cached?

Flutter's default service worker, we discovered, was optimized for performance over freshness. It cached aggressively, and Safari honored those cache directives more strictly than Chrome. While Chrome would occasionally check for updates, Safari trusted the cache completely.

We had created an app that was too good at being offline-capable. It was so offline-capable that it refused to go online even when updates were available.

The API Caching Surprise

While we were wrestling with the service worker caching the app itself, we discovered another problem: Safari was also caching API responses, the dynamic data that should always be fresh. Testers would pull-to-refresh on their dashboard and see the same stale data.

While annoying, the solution here was relatively simple: add a timestamp query parameter to every API request. Instead of requesting /api/feed, we'd request /api/feed?timestamp=1696789012345. This makes each request look unique to the browser, bypassing its cache.

/api/feed?timestamp=1696789012345

This pattern, sometimes called cache-busting, is a tried-and-true technique. But it was just the beginning of our caching odyssey.

Building an Update Mechanism

No website or app is ever really "done." They’re always going to need to be updated, especially in this case where we were using iterative development. If the browsers were going to hold on so tightly to old versions of our PWA, we'd need a way to notify users when updates were available and provide them with a clear path to get the latest version. This led us to implement a comprehensive update mechanism:

A version endpoint: Fortunately, we’d implemented version tracking from the start. We created an API endpoint that returned the current version of the deployed PWA. This endpoint was configured with aggressive no-cache headers to ensure browsers would always fetch fresh version information.

Periodic checking: The app would check for updates every 30 minutes in the background. When a new version was detected, users would see a friendly banner: "A new version is available. Update now!"

A custom service worker: We replaced Flutter's default service worker with one we could control. Our version used version-based cache names, so when we deployed an update, the new cache was completely separate from the old one.

The nuclear option: When users clicked "Update Now," we didn't just refresh the page. We cleared all browser caches, unregistered the service worker entirely, and forced a hard reload with a cache-busting parameter. We weren't taking any chances.

The Version Mismatch Problem

Our initial version-checking approach compared the app's version number against the backend's version number. Simple enough, right?

The problem emerged during QA testing. Sometimes the PWA and backend were deployed separately, and their version numbers weren't always updated in lockstep. Testers would see the "update available" banner even when there was nothing to update, creating confusion and eroding trust in the notification.

The solution was to move away from manually-managed version numbers entirely and instead use a (more elegant) solution based on git commit hashes. Since both the PWA and backend were built from the same repository, a deployment from the same commit would have matching hashes. If the hashes differed, an update was truly available.

This approach eliminated false positives and removed the need for manual version bumping. It was a win for both accuracy and developer productivity.

The Service Worker Strikes Back

Just when we thought we had caching under control, a new problem emerged. QA reported that after clicking "Update Now," the banner would disappear briefly and then come back. Some testers also saw error messages about failed API requests.

The culprit? Our service worker was intercepting API requests and failing due to CORS restrictions. When a service worker fetches a cross-origin resource, it operates under different security rules than a normal browser request. Our service worker was trying to be helpful by caching API responses, but it was actually breaking things.

The fix was to teach our service worker to mind its own business:

javascript
// If this is an API request or cross-origin, don't intercept if (url.pathname.includes("/api/") || url.hostname !== self.location.hostname) { return; // Let the browser handle it normally }

By explicitly excluding API requests from service worker interception, we let the browser's native fetch handle them with proper CORS support.

The Bootstrap Problem

There's a philosophical problem with update mechanisms: how do you update the update mechanism itself?

Anyone who had installed the PWA before we implemented our update banner would be stuck. They'd have the old, aggressive service worker that wouldn't check for updates. The only way for them to get our new update mechanism would be to manually clear their browser cache, the very thing our update mechanism was supposed to eliminate.

Fortunately, we caught this issue during development, before real users were affected. But it was a sobering realization: if this had made it to production, we would have had to communicate with affected users directly, providing instructions for clearing Safari's cache. It was a reminder that no amount of clever engineering can fully solve a problem that requires changing code that's already running on user devices, and why catching these issues in QA is so valuable. It was also a good reminder of the importance of pragmatic programming and to build in your "just in case"-type infrastructure, even for proof of concepts if they’re being put out in the real world.

The False Alarm

Just when we thought we'd finally conquered the caching beast, I noticed during some unrelated dev testing that the update banner had returned and wouldn't go away. No matter how many times I clicked "Update Now," it kept coming back.

I jumped on it like a five-alarm fire. After two months of cache-related bugs, my nervous system was primed for the worst. I started tearing through the code, checking service worker logic, reviewing cache invalidation, examining the version comparison. Everything looked correct.

Then it slowly dawned on me: I had manually deployed the PWA to test something, but the backend API hadn't been redeployed. The git hashes were different because they were different. The system was working exactly as designed.

The banner kept appearing because there genuinely was a mismatch between frontend and backend versions. It just wasn't a problem this time. It was our update mechanism doing its job, faithfully alerting users that something was out of sync.

We closed the ticket as "Won't Do" and had a good laugh. After months of fighting caching issues, we'd finally built something robust enough that its correct behavior looked like a bug.

Lessons for the Road

After two months of weekly releases with cache-related refinements, we emerged with battle-tested wisdom:

Safari PWA caching is different: Don't assume Chrome testing covers Safari. And further, don't assume iOS Safari testing covers macOS Safari. Test on the actual platforms your users will use.

Build cache-busting in from day one: Retrofitting cache invalidation is painful. Design your PWA with update mechanisms from the start. Consider how users will get updates before you write your first line of service worker code.

Service workers should stay in their lane: Keep your service worker focused on static assets. Let the browser handle API requests natively. The performance gains from caching API responses aren't worth the complexity and potential CORS issues.

Give users an escape hatch: Automated updates are great, but users need a manual way to force-refresh when things go wrong. A visible "check for updates" option builds trust and provides a fallback.

Version with automation: Manual version bumping doesn't scale and leads to mismatched deployments. Use git hashes or automated versioning to ensure your version indicators are always accurate.

Deploy frontend and backend together: If your version checking compares frontend and backend versions, deploy them from the same commit. Mismatched deployments lead to false update notifications.

The Bigger Picture

Our caching odyssey with our PWA reflects a broader tension in modern web development. We want apps that load instantly, work offline, and feel native. But we also want apps that update seamlessly and always show fresh content. These goals are fundamentally at odds.

PWAs give us powerful tools to balance these concerns, but those tools require careful handling. A service worker that caches too aggressively becomes a prison for stale content. One that caches too little sacrifices the offline experience that makes PWAs compelling.

The key is intentionality. Understand what you're caching, why you're caching it, and how users will get updates. Don't accept framework defaults without understanding their implications. And test, test, test—on every browser and platform your users might encounter.

Thanks to our thorough QA process and rapid iteration cycles, our client's app launched with robust update handling. Users get fresh content when they need it and cached content when they don't have connectivity. The update banner appears only when there's truly an update available, and clicking it actually works.

It took two months of iteration to get there, but none of these issues ever reached production users. The lessons learned will inform every PWA we build going forward. And if this story saves you even one week of debugging Safari caching issues, it will have been worth telling.

Resources

If you're building a PWA and want to avoid similar pitfalls, these resources were invaluable during our journey:

Like what you’ve read? Have questions? Have a PWA you want to bring to life? We’d love to hear from you →

Ready to Start Your Project?

Let's discuss how we can help bring your ideas to life with thoughtful design and robust development.