Published on

The Problem You Don’t Have... Until You Do: Solving the iOS Web App Cache Trap

Authors
  • avatar
    Name
    Manpreet Bhasin
    Twitter

Works on My Machine: The iOS Web App Edition

For someone to solve a problem, they first have to be aware that there is one. Caching that works in Chrome on your dev machine and Safari on your phone can still be completely broken once a page is bookmarked as a web app. Why didn't I think of that use case? Well, you're here now. Let's admit the oversight before QA catches it in the morning standup!

What's the problem?: If you’ve ever built a web app meant to be "Added to Home Screen" on an iPhone, you’ve likely encountered a ghost in the machine. You push a critical bug fix, you verify it on your desktop, you check it on your mobile Safari browser—it’s perfect.

Then, you open the bookmarked version on your iPhone. Old code. No update. No refresh button.

Without a native way to force a refresh, users stay stuck on old versions indefinitely. We can’t exactly ask customers to delete and re-save the app every time we deploy a patch. In Apple's world, bookmarked web apps are treated as isolated containers. Once they cache your assets, they cling to them like a digital barnacle. Here is how I fought the cache and (finally) won.

Solutions Tried :

Stage 1: The "Server-Side" Attempt

My first thought was to be aggressive at the server level. I used .htaccess to tell the phone: "Do not trust anything you have stored."

<IfModule mod_headers.c>
    Header set Cache-Control "no-cache, no-store, must-revalidate"
    Header set Pragma "no-cache"
    Header set Expires 0
</IfModule>

The Result: It helps for standard Safari tabs, but the bookmarked Home Screen app often ignores these headers once the initial manifest is saved. It prioritizes the local disk over the network to maintain that "offline-first" native feel.


Stage 2: The "Meta Tag" Hail Mary

If the server wouldn't convince it, maybe the HTML would. I added every "no-cache" meta tag ever conceived:

<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />

The Result: Total failure. If the iOS wrapper has already cached your index.html, it won't even download the new version to see these tags.


Stage 3: The Build Tool Secret (Vite Hashing)

This is where the strategy shifts. If the phone won't update the file at app.js, we stop giving it app.js.

Modern build tools like Vite use content-based hashing. Every time you change your code, Vite generates a new filename, like index-D7s8a9.js. This is the ultimate cache buster because the browser sees a completely new URL it has never seen before. Still wont work. Remember, html is cached!


Stage 4: The Final Boss (Service Workers & Workbox)

To tie it all together, we need a Service Worker to act as the orchestrator. It needs to detect the new Vite-hashed files, download them in the background, and then force the app to refresh.

Using the vite-plugin-pwa, we can automate this entire lifecycle in our vite.config.js:

import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate', // This is the magic switch
      workbox: {
        cleanupOutdatedCaches: true,
        skipWaiting: true,
        clientsClaim: true,
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
      },
    }),
  ],
})

How it works:

  • registerType: 'autoUpdate': This tells the Service Worker to check for updates frequently and, as soon as it finds one, prepare to take over.

  • skipWaiting: true: By default, a new Service Worker waits until all tabs are closed to activate. On iOS, a web app is rarely "closed" in that way. skipWaiting forces the new worker to kick out the old one immediately.

  • clientsClaim: true: This ensures that the moment the Service Worker activates, it immediately starts controlling the current page.

  • cleanupOutdatedCaches: true: This is the janitor. It deletes all those old hashed files (index-D7s8a9.js) from the phone's storage so your app doesn't take up unnecessary space.

Why this works:

  1. The Manifest Check: The phone checks your sw.js (which we keep fresh via .htaccess. This is autogenerated).
  2. Byte-Diff: It sees the Vite-hashed filenames have changed.
  3. Skip Waiting: The Service Worker installs and immediately takes control, killing the old version.
  4. Auto-Reload: The plugin triggers a window.location.reload(), and suddenly, the user is on the latest version.

How to Test If You’ve Fixed It

Before you tell your users it’s fixed, verify it in your dev environment:

  1. Open your app in Chrome and go to DevTools > Application > Service Workers.
  2. Make a small text change in your code and rebuild.
  3. Watch the "Status" in DevTools. You should see a new worker "waiting to activate" and then immediately "active."
  4. If your page reloads automatically, you’ve successfully bypassed the iOS trap.

Summary

Don't ask your users to delete their bookmark. That's a UX failure. Instead, use a three-tier defense:

  • .htaccess to keep the Service Worker script itself fresh.
  • Vite Hashing to ensure every code change has a unique URL.
  • Service Workers to force the update without user intervention.

We might be living in Apple's world, but with the right Service Worker configuration, we don't have to follow their caching rules.