The ghost of mobile cache — bfcache, pageshow, and the tab that refused to update
Portfolio·

The ghost of mobile cache — bfcache, pageshow, and the tab that refused to update

The ghost of mobile cache

There’s a special kind of bug that’s particularly maddening: the site works flawlessly in tests, in CI, on desktop… but on the end user’s phone it shows stale content.

That’s exactly what was happening with the Portfolio (samuelmedeiros.vercel.app) in early August. The site was pristine: 218 passing tests (28 Vitest suites), clean Next.js build, error-free Vercel deploy. Yet every so often a report would come in: “I opened the site on Chrome mobile and it was an old version.”

Context — what bfcache is and why it ignores your headers

Chrome mobile has a feature called bfcache (back-forward cache). When you navigate to another tab and come back, or close the app and reopen it, Chrome doesn’t make a new request — it restores the ENTIRE page from memory, including JavaScript state, scroll position, everything.

This is great for performance. Terrible for consistency.

The problem: bfcache completely ignores HTTP headers like Cache-Control: no-cache, no-store, must-revalidate. These headers control the traditional HTTP cache (disk cache, memory cache), but bfcache is a separate layer — it takes a snapshot of DOM + JS + state and stores it in the device’s RAM.

The Portfolio already had the vercel.json properly configured:

{
  "headers": [
  {
  "source": "/(.*)",
  "headers": [
  { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" },
  { "key": "Pragma", "value": "no-cache" },
  { "key": "Expires", "value": "0" }
  ]
  }
  ]
}

And still Chrome mobile served stale versions. Because bfcache doesn’t read those.

The diagnosis — pageshow and e.persisted

LifeLog (the Astro blog) had suffered the same problem a few days earlier. Samuel would open the blog on his phone, see old posts, manually refresh, and the new ones would appear. The solution applied there (commit 8f257c7) became the reference and was ported to the Portfolio (commit 283ebbc).

The diagnostic key: the pageshow event. It fires every time a page is shown — whether on initial load or on bfcache restoration. When the page comes from bfcache, the event.persisted property is true.

The fix in Portfolio’s layout.tsx:

// src/app/layout.tsx — bfcache guard
"use client";

import { useEffect } from "react";

function BfcacheGuard() {
  useEffect(() => {
  const handlePageshow = (e: PageTransitionEvent) => {
  if (e.persisted) {
  // Page was restored from bfcache — force reload
  window.location.reload();
  }
  };
  window.addEventListener("pageshow", handlePageshow);
  return () => window.removeEventListener("pageshow", handlePageshow);
  }, []);
  return null;
}

The BfcacheGuard component renders in the root layout, before any content. When Chrome mobile restores the tab from bfcache, pageshow fires with persisted: true, and the guard forces a window.location.reload() — which this time respects the Cache-Control headers and downloads the fresh version from the server.

Cross-project learning — why LifeLog saved the Portfolio

This is one of those cases where two seemingly independent projects benefit from the same fix. LifeLog (Astro 7, SSG) and Portfolio (Next.js 16, React 19) have completely different stacks — but the problem is in the platform (Chrome mobile), not the framework.

LifeLog was the canary in the coal mine: since it’s updated more frequently (new posts almost daily), Samuel noticed the problem there first. After testing Cache-Control in vercel.json and meta http-equiv in HTML (which also don’t affect bfcache), the pageshow + e.persisted solution proved to be the only one that actually works.

Then it was just a matter of porting: same concept, different syntax. In LifeLog it’s vanilla JS inline in the BaseLayout.astro script. In Portfolio it’s a React component BfcacheGuard with useEffect. Same logic, same result.

Current state — 218 tests, mature open source

The Portfolio is at a moment of maturity. The numbers:

Metric Value
Vitest tests 218 (28 suites)
ESLint 0 errors, 0 warnings
Components 80+ (with tests)
Mini-games 5 (iframe + React CDN)
i18n Full PT/EN
License MIT

The repository gained issue/PR templates (acd55d6), a contributing guide, and an explicit MIT license (d88eda2). What was a personal portfolio became a real open source project — with CI/CD that tests 218 assertions before every deploy.

The bfcache bug was the last major “silent bug” resolved. Now the site loads the correct version on any device, in any tab restoration scenario — without the user needing to manually refresh.

What’s next

With the solid foundation (218 tests, cache resolved, stable deploy), the next steps are:

  • Automated CI deploy — the Vercel deploy is still manual (vercel --prod), but the CI workflow (lint → test → build) already runs on every push
  • E2E Playwright in CI — integration tests run locally but not in GitHub Actions yet
  • PWA — service worker for intelligent offline cache (which, unlike bfcache, we control)
~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$