Caching
How long your changes take to reach users in the app, and what to do when they take too long
Caching
Your web app runs inside the app's embedded browser. It caches your site exactly as a browser does, using your own Cache-Control headers.
So when a user sees an old version of your site, the lifetime holding it there is almost always one your own server set. This page tells you how long each layer holds a change, how to shorten it, and what to check when the headers look right and it still won't update.
The short version
| What | Who controls it | How long | How to make it faster |
|---|---|---|---|
Your responses with Cache-Control | You | Exactly what you set | Set no-cache, or ship a new filename |
Your responses without Cache-Control | Nobody — the browser guesses | Unpredictable | Set a header. There is no other lever |
| The starti.app brand bundle | starti.app | Up to 10 minutes | Not possible — wait it out |
| The native app | App Store / Google Play | Until the user updates | Not possible |
Updating the app from the App Store or Google Play does not deliberately clear anything. The cache, cookies and local storage carry over to the new version.
Set your cache headers
Two rules, for two kinds of file. This pair is what makes deploys appear instantly and keeps caching effective — no single max-age does both.
Your HTML entry point — revalidate every time:
Cache-Control: no-cacheYour fingerprinted assets — cache indefinitely:
Cache-Control: public, max-age=31536000, immutableFingerprinting means the filename contains a hash of the contents, so app.4f2a91.js becomes app.8c7d03.js when the file changes. A new filename is a new cache entry, fetched immediately. Vite and Next.js do this by default; webpack and Rollup need [contenthash] in the output filename.
Only put immutable on filenames that actually contain a hash. On a fixed name like bundle.js it tells every browser to keep that exact file for a year, and you cannot take it back.
no-cache does not mean "do not cache". It means "cache it, but check with the server before reusing it" — which is what you want for HTML, since an unchanged file costs you a cheap 304 Not Modified. The directive that prevents storage entirely is no-store, and you rarely want it.
For Firebase Hosting, the two rules look like this:
"headers": [
{ "source": "**", "headers": [
{ "key": "Cache-Control", "value": "no-cache" } ] },
{ "source": "/assets/**", "headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }
]The order matters: when two rules match, the last one wins, so the broad ** rule has to come first or your hashed assets end up no-cache.
Headers are only half of it. A tab or app session that was already open when you deploy still asks for the old asset filenames, which no longer exist — so also handle chunk-load failures in your code by reloading the page once. Getting the headers right prevents the next user from hitting it; it does nothing for the session already running.
Making a change appear now
Users have no way to force a fresh fetch themselves, and the only lever that scales is to change the URL, because a cached entry is keyed by URL. (Your page can also call clearWebData(), but that signs the user out, so it is a recovery tool rather than a deployment strategy.)
- Assets — change the filename (
app.4f2a91.js→app.8c7d03.js), or append a query string (?v=2026-08-18). - HTML — you cannot rename your entry point, so it has to already be
no-cache. If it isn't, users stay on the old page until their copy expires, and there is nothing you can do from your side. Fix the header now so the next incident is recoverable.
Check what you're sending
curl -sD - -o /dev/null https://your-site.example/ | grep -i cache-controlTo watch it on a real device, attach a remote inspector and use the Network tab — see Debug Your App. This requires the test version of your app; remote inspection is disabled in production builds.
How long each layer holds a change
| Layer | Controlled by | Updates when |
|---|---|---|
| Your origin | You | Immediately on deploy |
| Your CDN or host, if any | You | Per your headers and your host's purge tools |
| The device's cache, inside the app | Your Cache-Control | When the lifetime you set expires |
| The starti.app brand bundle | starti.app | Within about 10 minutes of a deploy from the Manager |
| The native app | The app stores | When the user installs an update |
The third row is the one behind almost every "my change isn't showing" report.
The brand bundle
main.js and main.css are served from cdn.starti.app through a CDN and are currently sent with max-age=600 — 10 minutes. Deploying your brand from the starti.app Manager takes roughly 1–2 minutes to build and publish; after that, a user holding a cached copy picks up the new bundle within the 10-minute window. Worst case from pressing deploy to every user having it is therefore around a quarter of an hour.
You can check the current value yourself. The bundle comes in two variants and the User-Agent picks between them, so send the app's to see what the app gets:
# what the app loads
curl -sIL https://cdn.starti.app/c/YOUR_BRAND/main.js \
-H "User-Agent: starti.app/1.0.0" | grep -i cache-control
# what a desktop browser loads
curl -sIL https://cdn.starti.app/c/YOUR_BRAND/main.js | grep -i cache-controlThe native app
Only what is compiled into the app waits for a store update — the splash screen and the bundled offline page. The starting URL and the app icon can both be changed at runtime from your own page through the SDK, and those overrides are stored natively and survive app updates.
When it isn't the cache
If your headers are right and users still see old behaviour, one of these is usually why. None of them are HTTP caches and none respond to Cache-Control:
- A starting URL you set earlier. If your page has ever called the SDK to override the app's starting URL, that override is stored natively and persists across app updates — the app will keep opening the old address until you reset it. This looks exactly like a cached page and is the easiest cause to miss.
- Cookies. Persistent cookies survive restarts and app updates. Session cookies are normally dropped when the app process starts: WebKit does this on iOS, and the app does it explicitly on Android.
localStorage,sessionStorageandIndexedDB.localStorageandIndexedDBsurvive restarts and app updates; only your code,clearWebData(), Android's Clear storage, or an uninstall removes them.- App storage (
startiapp.Storage.app) — see Storage and data. Stored natively, and not touched byclearWebData().
Clearing web data (debugging only)
Your page can ask the app to clear web data through the SDK. It clears everything, so the user is signed out of any web session — use it for "reset app" and "log out", not as a routine cache-buster.
await startiapp.Storage.clearWebData();It clears the HTTP cache, cookies, and website storage (localStorage and IndexedDB).
The promise resolves once the app reports the clearing finished, so you can reload straight after it:
await startiapp.Storage.clearWebData();
location.reload();Neither platform reloads the page for you — that is yours to do.