●2.1.311 — Adds idle-hold options for self-hosted runners and fixes startup freezes and stalled interrupts (10/26)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 33 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…●2.1.311 — Adds idle-hold options for self-hosted runners and fixes startup freezes and stalled interrupts (10/26)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 33 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…
Back From Checkout, the Button Stayed on Processing: Why I Fixed It With pageshow, and the 3 Files My Audit Still Found
When you press Back from Stripe or another external page, a checkout button can stay stuck on Processing. I reproduce the bfcache cause in a real browser, compare pageshow with no-store, and add a repo audit script and a CLAUDE.md rule.
You open the checkout page, then press the browser's Back button. The button you land on is greyed out and still says "Processing…". No matter how often you press it, nothing happens.
I ran into this on the membership page of a small project I run as an indie developer. A reload fixes it, which is exactly why it is easy to miss. Someone who hesitated, went back, and found a dead button might simply leave.
The API call had not failed, either. The logs offer no clue for this symptom.
What happened: the page was frozen, then thawed
When you navigate away, the browser sometimes keeps the old page in memory in a place called bfcache (the Back-Forward Cache). On Back it thaws the page instead of loading it again.
React components are not remounted. The useState value stays as it was, so loading is still true. If the button says disabled={loading !== null}, it stays disabled.
A button that navigates to an external service has to be written for "the version of me that comes back", or its state stays stranded in the past.
The clue is persisted on the pageshow event. It is true only when the page was restored from bfcache.
First, I reproduced it in a real browser
Judging by feel makes "it's fixed" a weak claim. So I rebuilt the same structure without React, using just a loading variable and a label, and drove "press, navigate away, go back" with Playwright.
// bfcache-check.js — check in a real browser that the checkout button does not freeze on Back// Usage: node bfcache-check.js (Playwright must be installed)const http = require('http');const { chromium } = require('playwright');// Test page: a loading variable and a label reproduce the same structure as the React componentconst page_html = (guard, payUrl) => `<!doctype html><meta charset="utf-8"><title>plans</title><button id="join">Join</button><p id="state">idle</p><p id="evt">none</p><script> let loading = false; const $ = (id) => document.getElementById(id); $('join').onclick = async () => { loading = true; $('state').textContent = 'loading'; await new Promise((r) => setTimeout(r, 50)); // stands in for the /api/checkout round trip location.href = '${payUrl}'; // navigate to Stripe }; addEventListener('pageshow', (e) => { $('evt').textContent = 'persisted=' + e.persisted; ${guard ? "if (e.persisted) { loading = false; $('state').textContent = 'idle'; }" : ''} });</script>`;const serve = (port, handler) => new Promise((ok) => { const s = http.createServer(handler).listen(port, () => ok(s)); });const CASES = [ { name: 'A no guard', guard: false, noStore: false }, { name: 'B reset on pageshow', guard: true, noStore: false }, { name: 'C no guard + no-store', guard: false, noStore: true },];(async () => { let failed = 0; for (const c of CASES) { const pay = await serve(4102, (_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end('<title>pay</title>pay'); }); const html = page_html(c.guard, 'http://localhost:4102/pay'); const site = await serve(4101, (_, r) => { const h = { 'content-type': 'text/html' }; if (c.noStore) h['cache-control'] = 'no-store'; r.writeHead(200, h); r.end(html); }); // Playwright disables bfcache by default, so drop that flag at launch const browser = await chromium.launch({ channel: 'chromium', ignoreDefaultArgs: ['--disable-back-forward-cache'] }); const page = await browser.newPage(); await page.goto('http://localhost:4101/'); await page.click('#join'); await page.waitForURL(/\/pay/); // page.goBack() waits for a load event that never fires on bfcache restore, so use history.back() await page.evaluate(() => history.back()); await page.waitForTimeout(1200); const state = await page.textContent('#state'); const evt = await page.textContent('#evt'); const ok = state === 'idle'; if (!ok) failed++; console.log(`${ok ? 'PASS' : 'FAIL'} ${c.name.padEnd(22)} state=${state.padEnd(8)} ${evt} (Chromium ${browser.version()})`); await browser.close(); site.close(); pay.close(); } process.exit(failed ? 1 : 0);})();
These are the results with Playwright 1.63.0 and Chromium 153.
FAIL A no guard state=loading persisted=true (Chromium 153.0.8010.12)PASS B reset on pageshow state=idle persisted=true (Chromium 153.0.8010.12)PASS C no guard + no-store state=idle persisted=false (Chromium 153.0.8010.12)
Case A is the symptom I saw in production: right after returning, persisted=true but the label still said loading.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can reproduce the 'stuck on Processing after Back' symptom in a real browser and confirm for yourself that your fix actually worked
✦You can decide between resetting state on pageshow and stopping bfcache with Cache-Control: no-store, based on Back-navigation speed and how easily the bug returns
✦You can find, with an audit script, where the same hole is still hiding in your own code and close it before a bug report arrives
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Two surprises: a test that passes by default, and the PASS in case C
Running it taught me two things.
First, Playwright launches Chromium with --disable-back-forward-cache. Leave that flag in and even case A reports persisted=false, so every case passes. A Back-navigation test with default settings goes green without ever touching bfcache. The bug that freezes your button in production can never reproduce in that test.
Second, page.goBack() waits for a load event, and a bfcache restore does not fire one. It hung until the 30-second timeout. I switched to page.evaluate(() => history.back()) with a fixed 1.2-second wait.
Then there is case C. Adding Cache-Control: no-store made the symptom disappear. It looks fixed. But persisted=false shows the cause is still there: the page simply no longer enters bfcache.
Three ways to fix it, and why I chose pageshow
I compared the options by what each one costs.
Approach
Speed on Back
How easily it returns
Effort
Reset state on pageshow
Stays fast (just thaws the page)
Comes back wherever a button forgets it
One small effect per component
Cache-Control: no-store on the HTML
Reloads every time (slower)
May return if the browser's behavior changes
One header, but it affects the whole page
location.reload() on return
Reloads (slower)
Wipes anything half-typed
Needs pageshow anyway
no-store slows down Back for the whole page just to protect one button, which is a poor trade on a site that also has article pages. Reload still needs a pageshow listener, so it saves no work.
So I decided to add a pageshow effect to each component that owns a checkout button. This is the code as it stands.
// Reset the in-progress state only when the page is restored from bfcache after BackuseEffect(() => { const handlePageShow = (e: PageTransitionEvent) => { if (e.persisted) { setLoading(null); setError(""); } }; window.addEventListener("pageshow", handlePageShow); return () => window.removeEventListener("pageshow", handlePageShow);}, []);
I narrow it with e.persisted so that normal loads do nothing. Clearing error too prevents a stale failure message from lingering.
The code I thought was fixed was still in the repository
I had added this guard to the membership page and the support page. What I could not find in my own records was a check for "are there other buttons shaped like this?"
Counting grep results by eye felt unreliable, so I turned the criteria into a script.
#!/usr/bin/env python3"""Find files that navigate to an external page without resetting state on pageshow.Usage: python3 audit_bf.py src"""import re, sys, pathlibNAV = re.compile(r'(window\.)?location\.(href\s*=|assign\()')LOAD = re.compile(r'const \[(\w*[lL]oading\w*|\w*[pP]rocessing\w*|\w*[bB]usy\w*),\s*set\w+\]')SHOW = re.compile(r'addEventListener\(\s*["\']pageshow["\']')hits = []for p in sorted(pathlib.Path(sys.argv[1]).rglob('*.tsx')): t = p.read_text(encoding='utf-8', errors='ignore') if not NAV.search(t): continue has_load = bool(LOAD.search(t)); has_show = bool(SHOW.search(t)) status = 'OK' if has_show else ('NEEDS-FIX' if has_load else 'NO-STATE') hits.append((status, str(p), has_load, has_show))for s, p, l, sh in hits: print(f'{s:6} {p} (loading state={l}, pageshow={sh})')print(f'-- files with external navigation {len(hits)} / needs fix {sum(1 for h in hits if h[0]=="NEEDS-FIX")}')sys.exit(1 if any(h[0] == 'NEEDS-FIX' for h in hits) else 0)
The rule is the intersection of three conditions: it navigates away, it holds an in-progress state, and it has no pageshow listener. Files matching all three are flagged.
Of six files that navigate to an external page, three were unguarded. All three call /api/checkout and send the user to a payment page, as purchase and tip buttons. I had fixed the membership page and assumed "the checkout buttons are fixed."
I plan to add the same pageshow effect to those three and confirm it with the same test. The real gain was noticing before anyone reported the symptom.
This audit misses things too
So that you do not over-trust the script, here are its limits.
It is a regular expression. If navigation is wrapped in another hook or function, it will not be seen, and OK only means the string pageshow exists
A pageshow listener can still be wrong if it resets a different state. The real-browser reproduction is the final word
I tested Chromium only. I have heard that Safari is aggressive about bfcache, but I have not confirmed that myself. If you have a device where it reproduces, try the same steps
Trying it yourself, and the pitfalls
When I apply this to a project, I think this order is the safest.
1. Run the reproduction first
Run node bfcache-check.js and confirm that case A FAILs. If A passes, check whether --disable-back-forward-cache is really removed, or suspect the Playwright and Chromium pairing. A fix you cannot reproduce first is a fix you cannot verify.
2. Audit the repo and count what needs fixing
The NEEDS-FIX lines of python3 audit_bf.py src are your list. Count every entrance that sends the user off-site, not just the checkout button in production: purchase, tip, sign-in, and so on.
3. Confirm PASS with the same test
After the fix, open your own component (with a stub for /api/checkout) instead of the test page and run the same steps. If the fixed 1.2-second wait turns out flaky, poll until #state becomes idle. I start with the fixed wait and switch only when it wobbles.
I hit three pitfalls along the way.
page.goBack() keeps waiting on a bfcache restore. Use history.back()
With Playwright's default launch options the production symptom never reproduces in the test
The error message lives in state too, so resetting only loading leaves the previous failure on screen. Reset both on pageshow
One line for when you ask Claude Code to write a checkout button
I was not confident I would remember this check every time a new external-navigation button appeared. So I plan to add the following to CLAUDE.md.
## Buttons that navigate to an external service- A button that sends the user to another page (Stripe, OAuth, and so on) must hold a `loading`-style state, and the same component must reset it on `pageshow` (only when `e.persisted` is true).- After adding or changing one, fix things until `python3 audit_bf.py src` exits 0.
That makes "run the audit until it passes" part of the same task whenever you ask for a new button. It follows the same idea as the allow-rule audit: leave the prevention step in the procedure. The allow-rule audit article is a companion read. For the payment side, see Stripe subscriptions in production and testing webhooks locally.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.