diff --git a/html/browsers/browsing-the-web/back-forward-cache/README.md b/html/browsers/browsing-the-web/back-forward-cache/README.md
new file mode 100644
index 000000000000000..99dff58b7be3820
--- /dev/null
+++ b/html/browsers/browsing-the-web/back-forward-cache/README.md
@@ -0,0 +1,31 @@
+# Dispatcher/executor framework
+
+In the BFCache tests, the main test HTML
+
+1. Opens new executor Windows using `window.open()` + `noopener` option, and
+2. Injects scripts to / receives values from the executor Windows via send()/receive() methods provided by
+ [the dispatcher/executor framework of COEP credentialless](../../../cross-origin-embedder-policy/credentialless/README.md)
+
+because less isolated Windows (e.g. iframes and `window.open()` without `noopener` option) are often not eligible for BFCache (e.g. in Chromium).
+
+# BFCache-specific helpers
+
+- [resources/executor.html](resources/executor.html) is the BFCache-specific executor and contains helpers for executors.
+- [resources/helper.sub.js](resources/helper.sub.js) contains helpers for main test HTMLs.
+
+In typical A-B-A scenarios (where we navigate from Page A to Page B and then navigate back to Page A, assuming Page A is (or isn't) in BFCache),
+
+- Call `prepareNavigation()` on the executor, and then navigate to B, and then navigate back to Page A.
+- Call `assert_bfcached()` or `assert_not_bfcached()` on the main test HTML, to check the BFCache status.
+- Check other test expectations on the main test HTML.
+
+Note that
+
+- `await`ing `send()` calls (and other wrapper methods) is needed to serialize injected scripts.
+- `send()`/`receive()` uses Fetch API + server-side stash.
+ `prepareNavigation()` suspends Fetch API calls until we navigate back to the page, to avoid conflicts with BFCache eligibility.
+
+# Asserting PRECONDITION_FAILED for unexpected BFCache eligibility
+
+To distinguish failures due to unexpected BFCache eligibility (which might be acceptable due to different BFCache eligibility criteria across browsers),
+`assert_bfcached()` and `assert_not_bfcached()` asserts `PRECONDITION_FAILED` rather than ordinal failures.
diff --git a/html/browsers/browsing-the-web/back-forward-cache/events.html b/html/browsers/browsing-the-web/back-forward-cache/events.html
new file mode 100644
index 000000000000000..159d494dc11226b
--- /dev/null
+++ b/html/browsers/browsing-the-web/back-forward-cache/events.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
diff --git a/html/browsers/browsing-the-web/back-forward-cache/resources/back.html b/html/browsers/browsing-the-web/back-forward-cache/resources/back.html
new file mode 100644
index 000000000000000..39a0a73b68cd611
--- /dev/null
+++ b/html/browsers/browsing-the-web/back-forward-cache/resources/back.html
@@ -0,0 +1,4 @@
+
+
diff --git a/html/browsers/browsing-the-web/back-forward-cache/resources/executor.html b/html/browsers/browsing-the-web/back-forward-cache/resources/executor.html
new file mode 100644
index 000000000000000..1ed8ae056f36576
--- /dev/null
+++ b/html/browsers/browsing-the-web/back-forward-cache/resources/executor.html
@@ -0,0 +1,112 @@
+
+
+
diff --git a/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js b/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js
new file mode 100644
index 000000000000000..f5757188bc03a01
--- /dev/null
+++ b/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js
@@ -0,0 +1,94 @@
+// Helpers called on the main test HTMLs.
+// Scripts in `send()` arguments are evaluated on the executors
+// (`executor.html`), and helpers available on the executors are defined in
+// `executor.html`.
+
+const idThis = token();
+
+const originSameOrigin =
+ location.protocol === 'http:' ?
+ 'http://{{host}}:{{ports[http][0]}}' :
+ 'https://{{host}}:{{ports[https][0]}}';
+const originSameSite =
+ location.protocol === 'http:' ?
+ 'http://{{host}}:{{ports[http][1]}}' :
+ 'https://{{host}}:{{ports[https][1]}}';
+const originCrossSite =
+ location.protocol === 'http:' ?
+ 'http://{{hosts[alt][www]}}:{{ports[http][0]}}' :
+ 'https://{{hosts[alt][www]}}:{{ports[https][0]}}';
+
+const executorPath =
+ '/html/browsers/browsing-the-web/back-forward-cache/resources/executor.html?uuid=';
+const backPath =
+ '/html/browsers/browsing-the-web/back-forward-cache/resources/back.html';
+
+// On the executor with uuid `idTarget`: Evaluates the script `expr`, and
+// On the caller: returns a Promise resolved with the result of `expr`.
+// This assumes the result can be serialized by JSON.stringify().
+async function evalOn(idTarget, expr) {
+ await send(idTarget, `await send('${idThis}', JSON.stringify(${expr}));`);
+ const result = await receive(idThis);
+ return JSON.parse(result);
+}
+
+// On the executor with uuid `idTarget`:
+// Evaluates `script` that returns a Promise resolved with `result`.
+// On the caller:
+// Returns a Promise resolved with `result`
+// (or 'Error' when the promise is rejected).
+// This assumes `result` can be serialized by JSON.stringify().
+async function asyncEvalOn(idTarget, script) {
+ send(idTarget, `
+ try {
+ const result = await async function() { ${script} }();
+ await send('${idThis}', JSON.stringify(result));
+ }
+ catch (error) {
+ await send('${idThis}', '"Error"');
+ }`);
+ const result = await receive(idThis);
+ return JSON.parse(result);
+}
+
+async function runEligibilityCheck(script) {
+ const idA = token();
+ window.open(executorPath + idA, '_blank', 'noopener');
+ await send(idA, script);
+ await send(idA, `
+ prepareNavigation();
+ location.href = '${originCrossSite + backPath}';
+ `);
+ await assert_bfcached(idA);
+}
+
+async function getBFCachedStatus(idTarget) {
+ const [loadCount, isPageshowFired] =
+ await evalOn(idTarget, '[window.loadCount, window.isPageshowFired]');
+ if (loadCount === 1 && isPageshowFired === true) {
+ return 'BFCached';
+ } else if (loadCount === 2 && isPageshowFired === false) {
+ return 'Not BFCached';
+ } else {
+ // This can occur for example when this is called before first navigating
+ // away (loadCount = 1, isPageshowFired = false), e.g. when
+ // 1. sending a script for navigation and then
+ // 2. calling getBFCachedStatus() without waiting for the completion of
+ // the script on the `idTarget` page.
+ assert_unreached(
+ `Got unexpected BFCache status: loadCount = ${loadCount}, ` +
+ `isPageshowFired = ${isPageshowFired}`);
+ }
+}
+
+// Asserts that the executor with uuid `idTarget` is (or isn't, respectively)
+// restored from BFCache.
+// These should be used with prepareNavigation() (see `../README.md`).
+async function assert_bfcached(idTarget) {
+ const status = await getBFCachedStatus(idTarget);
+ assert_implements_optional(status === 'BFCached', 'Should be BFCached');
+}
+async function assert_not_bfcached(idTarget) {
+ const status = await getBFCachedStatus(idTarget);
+ assert_implements_optional(status !== 'BFCached', 'Should not be BFCached');
+}
diff --git a/lint.ignore b/lint.ignore
index 1ca2a941aabee0c..3d68876598cd083 100644
--- a/lint.ignore
+++ b/lint.ignore
@@ -337,6 +337,7 @@ SET TIMEOUT: resources/test/tests/unit/promise_setup.html
SET TIMEOUT: resources/testharness.js
SET TIMEOUT: scheduler/tentative/current-task-signal-async-abort.any.js
SET TIMEOUT: scheduler/tentative/current-task-signal-async-priority.any.js
+SET TIMEOUT: html/browsers/browsing-the-web/back-forward-cache/resources/executor.html
# setTimeout use in reftests
SET TIMEOUT: acid/acid3/test.html