Skip to content

App Updates

Long-lived browser tabs keep running the bundle they first loaded. After you deploy, those tabs stay on the old frontend until the user refreshes. The update module flips a Convex config key as the last deploy step. Every client already subscribes to public config, so the flip notifies every open tab instantly. No polling, no service worker.

Each frontend build embeds its version (__APP_VERSION__, injected at build time from the root package.json plus the commit SHA). The backend holds the currently deployed version in the config table.

  1. Deploy backend — bunx convex deploy (keep functions backward-compatible)
  2. Deploy frontend — build and publish the new bundle to your CDN
  3. Flip the version signal — the last step, once the new bundle is actually served:
Terminal window
bunx convex run modules/core/config/config_internal:setAppVersion \
'{"version": "1.7.0"}' --prod

The ordering is load-bearing: the flip is the announcement, so clients that refresh must land on the new build. See .github/workflows/demo-deploy.yml for a working example — its final “Publish version signal” step flips the key after the Cloudflare Pages deploy.

The version you flip must equal the deployed build’s version — i.e. the root package.json version the bundle embedded. The example workflow reads it straight from package.json (node -p "require('./package.json').version") rather than the release tag, so a tag cut without bumping package.json can’t make the fresh bundle declare itself outdated. If you flip by hand, pass that same version.

Config key Effect when the running build is older
release.appVersion Soft: a persistent toast offers a refresh; hidden tabs reload silently so most users just come back to a fresh app
release.minSupportedVersion Forced: the app is replaced by an “Update required” screen. Reserved for breaking API changes

Pass minSupportedVersion to the same mutation to raise the floor:

Terminal window
bunx convex run modules/core/config/config_internal:setAppVersion \
'{"version": "2.0.0", "minSupportedVersion": "2.0.0"}' --prod

Versions compare on major.minor.patch only — build metadata (+sha) is ignored. An empty value (the default) never gates, so fresh installs are unaffected until CI first flips the key.

The forced floor has no in-app recovery. The “Update required” screen replaces the whole app — sign-in and the admin portal included — so a floor set above the deployed build locks everyone out, including the admin who would lower it. Two guards keep this safe: setAppVersion refuses a minSupportedVersion higher than the version it’s announcing (you can’t require a build newer than what’s deployed), and admin config.upsert rejects any release.* key. The floor is only writable through this internal mutation. If you ever strand yourself, lower it from the CLI: bunx convex run modules/core/config/config_internal:setAppVersion '{"version": "<deployed>", "minSupportedVersion": ""}' --prod.

Omitting minSupportedVersion leaves the previous floor unchanged. Pass "" to clear it.

As a safety net, the app also listens for Vite’s vite:preloadError event: if a stale tab lazy-loads a chunk that no longer exists on the CDN after a deploy, it reloads once instead of crashing.

The update flow assumes index.html revalidates on every load while hashed assets cache forever. Two files ship the right policy (no-cache for HTML, immutable for /assets/*), one per host:

  • Vercel — apps/web/vercel.json (Cloudflare Pages ignores it).
  • Cloudflare Pages — apps/web/public/_headers (Vercel ignores it). On Pages this file is the only source of truth, so it carries the security headers and the cache policy. Pages joins duplicate headers from multiple matching rules, so the /assets/* rule detaches (! Cache-Control) the catch-all’s value before setting immutable.

Keep the security-header set in the two files in sync. If you put Cloudflare’s CDN in front of another host, add a cache rule that bypasses the edge cache for text/html (or purge on deploy) — an edge-cached index.html silently defeats the mechanism.

Adopting this in an app that already has a _headers file: merge, don’t replace. If your app ships its own Content-Security-Policy (say, a pinned inline-script sha256), keep your CSP line and add the cache rules from this module’s _headers — dropping in RED’s file wholesale would clobber your CSP, and keeping only yours leaves index.html cacheable and breaks the update signal.