BFCache Lifecycle Resumption: Document Generations, Stale Async Results, and Side-Effect Reconciliation
A page restored via the back button is not a normal reload; the JS heap and stale state can come back as-is. This article uses a pagehide/pageshow contract, resume generations, and a server operation receipt to prevent duplicate submissions and the continued use of stale permissions.
Key Points
- Topic Focus: TypeScript、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
When the browser goes back, it may restore the entire Document and JavaScript heap from the back/forward cache instead of running a cold start again. Old variables, the DOM, and unfinished intents remain, while permissions, orders, or feature flags on the server have already changed. Treating pageshow as load and re-running a submission creates duplicate writes; doing nothing lets an expired account keep showing sensitive content. Web Performance RUM reminds us not to depend on unload; this article defines the correctness contract for resumption.
The Document identity stays the same across resumption; the resume generation must advance
Publish pageLifecycle.v1, pinning the app release, route contract, document instance ID generation rules, the list of resumable state, resources that must be closed, resumption validation items, the maximum staleness window, and the fallback. A Document's documentInstanceId does not change across a BFCache round trip; resumeGeneration is incremented monotonically on each pageshow where event.persisted === true. A hard reload or a new navigation creates a new document ID.
ACTIVE -> PAGEHIDE_PENDING -> SUSPENDED_CANDIDATE
-> RESTORING -> ACTIVE | RELOAD_REQUIRED
pagehide.persisted indicates that the browser intends to keep the page; it does not persist submission credentials. The handler stops taking on new work, saves a purely local UI checkpoint, and closes WebSockets, locks, and observers that would hurt cache eligibility or can no longer be safely resumed. Critical business writes must not rely on sendBeacon to "take one last gamble" on the way out; beacon is only suitable for telemetry that is already allowed to be lost or is idempotent on the server.
Do not add a permanent unload handler to simulate the lifecycle. Whether BFCache is actually entered is decided by the browser; normal destruction, crashes, and memory pressure can still make the page disappear. Facts that need to survive destruction must live on the server, in committed local storage, or in a user export, not only in the JS heap. Local-First AI Apps explains local-first tradeoffs; here we do not treat BFCache as a database.
On resumption, first revoke the eligibility of old results, then reconnect
The first step on entering RESTORING is to close the ACTIVE gate and raise the generation, so that fetches, timers, subscription callbacks, and worker messages created before pagehide cannot update the current DOM or trigger the next phase even if they arrive late. Then, in parallel, revalidate the session/auth epoch, route resource version, feature flags, CSRF/submission capability, Service Worker controller release, and server operation status; if any critical validation is unknown, keep a read-only mask or move to RELOAD_REQUIRED.
After validation completes, generate resumeReceipt.v1, binding the document instance, resume generation, auth/session epoch, route version, flag/policy generation, controller release, operation reconciliation root, a summary of validation results, and a short validity period. The only resumption acceptance point is activateResume, which uses CAS to confirm it is still the same document/generation, that the state is still RESTORING, and that all dependency versions are unchanged, then saves the receipt and advances to ACTIVE. A new pageshow, a controllerchange, a logout/revocation notice, or resource invalidation closes the gate, discards the entire round of results, and raises the generation; you cannot just re-run one of those checks.
All DOM commits and subsequent I/O carry both the generation at callback creation time and the active receipt ID; even if the callback was newly created after resumption, as long as the gate is not yet open, the receipt has expired, or the ID is no longer the current value, it can only be dropped or revalidated. The server still performs real-time authorization and resource preconditions on every sensitive request; the receipt is only the page's resumption eligibility, not a long-term token that bypasses subsequent revocations.
Real-time connections are rebuilt with the new generation and a server resume cursor; you cannot reuse an in-memory object from the old socket that "looks like it's still open". A cross-tab leader must re-contend for the Web Locks Leader Fencing; the old owner identity cannot be restored with the heap. If the Service Worker controller changed during the suspension, the page re-handshakes per the client protocol of Service Worker Release Activation.
When the user explicitly submits a write, the page first obtains a stable operationId and payload digest, which the server accepts atomically; the page only caches a receipt reference. After resumption, query the original operation: SUCCEEDED shows the existing result, PENDING/UNKNOWN continues reconciliation, and only an authoritative NOT_ACCEPTED allows a retry with the same key. The same button may still look clickable, but it cannot generate a second operation. HTTP Idempotency Key Correctness handles server-side effect binding; this lifecycle only governs whether the UI is eligible to initiate or display.
Replay real back navigation, freezing, and permission changes
Automation runs A -> B -> back across the supported matrix of Chromium, Firefox, and WebKit, recording pageshow.persisted; it also injects revocation before resumption, a second revocation during validation or a controllerchange, two consecutive pageshow events, a new callback created before the ACTIVE gate opens, an old fetch arriving late, a half-open socket, a multi-tab leader handoff, a write that succeeded but whose operation receipt the page did not record, a non-BFCache reload due to memory pressure, and a forced fallback when a form has unsaved content. Assert that DOM commits/I/O with an old generation or a non-current resume receipt are 0, that duplicate business effects during resumption are 0, and that the visible window of revoked content does not exceed the policy SLO.
Gate requirements are: 100% coverage of the critical revalidation on the resumption path, 0 DOM/I/O acceptances without an active resume receipt, 0 old callback acceptances, 0 old receipt acceptances after a dependency changes during validation, 0 duplicate effects for the same operation, resumption interaction p95 below 500 ms, and that every BFCache resumption failure has a RELOAD_REQUIRED reason. The hit rate is only observed bucketed by browser capability; there is no aggregate target that forces every page to be cacheable. First, only collect pagehide/pageshow receipts, then restore connections for read-only routes, and finally open up forms with operation reconciliation. During an incident, restored pages can be made to do an immediate controlled reload, trading off speed while preserving server idempotency and unsaved checkpoints; you cannot paper over races by adding unload or clearing state.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.