Speeding Up Navigations: bfcache, Speculation Rules, Router Preloading and Brotli/zstd Compression
To speed up moving between pages, do not block bfcache for the back button, use Router preloading inside the SPA, Speculation Rules for document navigations and the right compression level. In Bookstore the in-app transition went from 810 ms to 207 ms and the catalog list from 2.8 s to 1.6 s; we measured that Speculation Rules do nothing for routerLink links and that ASP.NET Core's default Brotli level sent the JSON as 160 KB.
Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports, and @angular/ssr 22.2.7), Node.js 24, web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158. Browser support data comes from MDN browser-compat-data 8.1.9 (2026-11-12).
Predict First
So far we have always looked at the first load of a page. Users, however, move between pages: in Bookstore, the online bookstore used as the shared example across this site's series, they go from the home page to a book, come back, go to the catalog. Clicking the "View details" link on the home page takes 810 ms until the book title is on the screen with the phone profile. Predict: which of these shortens that time the most: prerendering the book page in advance with Speculation Rules, Angular Router's preloading, or better compression on the server?
bfcache: Free Speed for the Back Button
bfcache, the back/forward cache, freezes the previous page in memory together with its JavaScript state when the user goes to another page, and brings it back as it was on the back button; no network request, no parsing, no rendering. It only works for document navigations, that is, full page loads; Angular Router's in-app transitions stay in the same document anyway and do not use bfcache. We wrote a lab script (bfcache.mjs) that makes a full navigation from the SSR book page to the catalog page and goes back. Playwright starts Chromium with --disable-back-forward-cache by default, and the default headless shell has no bfcache at all; for this measurement we open Playwright's full Chromium build in new headless mode and without that flag:
// bfcache/prerender labs: Playwright starts Chromium with --disable-back-forward-cache, and its default
// headless shell has no bfcache; 'full' uses Playwright's full Chromium build in new headless mode instead
export function launch({ full = false } = {}) {
return full
? chromium.launch({ channel: 'chromium', args: chromiumArgs, ignoreDefaultArgs: ['--disable-back-forward-cache'] })
: chromium.launch({ args: chromiumArgs });
}
await page.goto(url, { waitUntil: 'networkidle', timeout: 120_000 });
await page.evaluate(() => (window.__marker = 'same page object'));
await page.goto(nextUrl, { waitUntil: 'networkidle', timeout: 120_000 });
// --cookie: the next page changes a cookie (e.g. adds a book to the cart)
if (values.cookie) await page.evaluate(() => (document.cookie = 'cart=9781000000009; path=/'));
const started = Date.now();
await page.goBack({ waitUntil: 'commit' });
await page.locator('app-book-page h1').waitFor();
const ms = Date.now() - started;
const restored = (await page.evaluate(() => window.__marker)) === 'same page object';
$ node bfcache.mjs http://127.0.0.1:5300/books/9781000000009 http://127.0.0.1:5300/books
back to /books/9781000000009: restored from bfcache, book title visible after 50 ms
RUM reports: FCP:navigate TTFB:navigate CLS:navigate LCP:navigate TTFB:back-forward-cache FCP:back-forward-cache LCP:back-forward-cache INP:back-forward-cache CLS:back-forward-cache
$ node bfcache.mjs http://127.0.0.1:5300/books/9781000000009 http://127.0.0.1:5300/books --no-store
back to /books/9781000000009: restored from bfcache, book title visible after 40 ms
RUM reports: FCP:navigate TTFB:navigate CLS:navigate LCP:navigate TTFB:back-forward-cache FCP:back-forward-cache LCP:back-forward-cache INP:back-forward-cache CLS:back-forward-cache
$ node bfcache.mjs http://127.0.0.1:5300/books/9781000000009 http://127.0.0.1:5300/books --no-store --cookie
back to /books/9781000000009: loaded again (a new page load)
not restored because (CDP): CacheControlNoStoreCookieModified
notRestoredReasons: [{"reason":"response-cache-control-no-store"}]
$ node bfcache.mjs http://127.0.0.1:5300/books/9781000000009 http://127.0.0.1:5300/books --unload
back to /books/9781000000009: restored from bfcache, book title visible after 38 ms
RUM reports: FCP:navigate TTFB:navigate CLS:navigate LCP:navigate TTFB:back-forward-cache FCP:back-forward-cache LCP:back-forward-cache INP:back-forward-cache CLS:back-forward-cache
In every case the book page came back from bfcache in 40-50 ms. Two results matter. The first is unload: adding an empty unload listener to a page used to turn bfcache off; according to web.dev, Chrome no longer runs this event, and the page was restored. In Firefox an unload listener is still a blocker, so use pagehide. The second is Cache-Control: no-store: according to Chrome's documentation, Chrome also puts no-store pages into bfcache under limited conditions (rolled out to all users in 2025), but if a cookie changes in the meantime the page is not restored. The third run shows this: after writing a cookie as if a book had been added to the cart, the page was loaded again. Other browsers may still keep no-store pages out; use no-store only on truly sensitive pages.
You can also learn from RUM why a page was not restored: in Chromium the notRestoredReasons field of the new load's navigation entry gives the reason (Chrome 125+ according to browser-compat-data, not in Firefox and Safari). web-vitals also reports metrics again for a page restored from bfcache; since the rum.ts from the first post already sends navigationType, restored visits show up separately as back-forward-cache. They need to be kept apart, otherwise bfcache's 40 ms returns make p75 look better than it is. The restored page's data may be stale (stock, price); refresh it in the pageshow event when event.persisted is true.
Speculation Rules and SPAs
Speculation Rules tell the browser to prepare the page the user is about to visit: prefetch only downloads the HTML, prerender renders the page completely in an invisible tab and shows it instantly on click. eagerness decides when to start: immediate right away, moderate after hovering over a link for about 200 ms on desktop, conservative as the click starts. According to browser-compat-data, speculationrules has been in Chrome since 109 (prefetch since 110), prerender is Chromium-only, prefetch is coming in the Safari 27.3 beta, and Firefox has it only in its preview build, partially. We added a rule for book pages to Bookstore's HTML template:
<!-- Prerender a book page when the user hovers over a link to it (Chromium; ignored elsewhere) -->
<script type="speculationrules">
{
"prerender": [{ "where": { "href_matches": "/books/*" }, "eagerness": "moderate" }]
}
</script>
$ node speculation.mjs http://127.0.0.1:5300/
speculation: prefetch Running (PrefetchNotFinishedInTime), prerender Failure (PrerenderingDisabledByDevTools), prefetch Ready (PrefetchSuccessfulButNotUsed)
click -> book title after 226 ms, same document (Angular router navigation)
$ node speculation.mjs http://127.0.0.1:5300/ --plain
speculation: prefetch Running (PrefetchNotFinishedInTime), prerender Failure (PrerenderingDisabledByDevTools), prefetch Ready (PrefetchSuccessfulButNotUsed), prefetch Success (PrefetchResponseUsed)
click -> book title after 97 ms, new document: delivery navigational-prefetch, TTFB 2 ms
$ node speculation.mjs http://127.0.0.1:5300/ --plain --no-rules
speculation: none
click -> book title after 250 ms, new document: delivery (network), TTFB 152 ms
There are three results. First, a link clicked through Angular's routerLink is not a document navigation: the Router catches the click and navigates within the same document, and the prepared page is not used (PrefetchSuccessfulButNotUsed). In an SPA, Speculation Rules make sense for links coming from outside the app or between apps (for example from a marketing page to a book page), for plain <a href> links without routerLink, and for SSR multi-page sites. Second, for a plain link the prefetched HTML was used: TTFB went from 152 ms to 2 ms, and the time from the click to the book title from 250 ms to 97 ms. Third, a limit of the lab: Chromium turns prerendering off while automation (the DevTools protocol) is attached (PrerenderingDisabledByDevTools) and uses the prefetched response instead. We could not measure prerendering itself with Playwright; you can try it by hand in the Speculative loads section of DevTools' Application panel.
The pitfall of prerendering is side effects: the prerendered page runs its JavaScript, and its load event fires before the user sees it. Our analytics loader ran on load, so it would count a page view even if the user never clicked. When document.prerendering is true, we leave loading to the prerenderingchange event:
<script>
// Analytics after the load event: it no longer competes with main.js for bandwidth
const loadAnalytics = () => {
const script = document.createElement('script');
script.src = 'http://analytics.thirdparty.test:5302/analytics.js';
document.head.append(script);
};
// A prerendered page also fires "load" before the user sees it: count the page view on activation
if (document.prerendering) {
document.addEventListener('prerenderingchange', () => loadAnalytics(), { once: true });
} else {
addEventListener('load', loadAnalytics);
}
</script>
Because prerendering did not run in the lab, we could not see this branch run in a real prerender; we verified the branch for a normal load. web-vitals measures metrics of prerendered pages from activationStart and reports navigationType as prerender. Do not put URLs that change something, such as adding to the cart or signing in, into the rules; start with moderate or conservative eagerness and watch the server load.
Router Preloading: Transitions Inside the SPA
The tool that speeds up transitions inside an SPA is Angular Router's preloading. In the JavaScript cost post we made the book page a lazy route; when you click a book on the home page, its chunk downloads first and the API requests go out after that. PreloadAllModules downloads every lazy route, including the back office's report page. Instead, we wrote a small strategy that loads only marked routes:
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { EMPTY, Observable } from 'rxjs';
// Preloads only the lazy routes marked with data: { preload: true }, after the first navigation;
// the back office (admin) is never downloaded for customers
@Injectable({ providedIn: 'root' })
export class FlaggedPreloading implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : EMPTY;
}
}
{ path: 'books', loadComponent: () => import('./catalog/catalog-page').then((m) => m.CatalogPage), title: 'Catalog - Bookstore', data: { preload: true } },
{ path: 'books/:isbn', loadComponent: () => import('./catalog/book-page').then((m) => m.BookPage), title: 'Book - Bookstore', data: { preload: true } },
provideRouter(routes, withComponentInputBinding(), withPreloading(FlaggedPreloading)),
click -> book title after 835 ms, same document (Angular router navigation)
click -> book title after 802 ms, same document (Angular router navigation)
click -> book title after 814 ms, same document (Angular router navigation)
click -> book title after 199 ms, same document (Angular router navigation)
click -> book title after 209 ms, same document (Angular router navigation)
click -> book title after 207 ms, same document (Angular router navigation)
In three measurements the time from the click to the book title went from 802-835 ms to 199-209 ms. We logged the requests after the click with nav-requests.mjs; first without preloading, then with it:
req 129 /chunk-JOppahxN.js
done 259 /chunk-JOppahxN.js
req 272 /api/books/9781000000009
req 272 /api/books/9781000000009/similar?count=8
done 438 /api/books/9781000000009
done 447 /api/books/9781000000009/similar?count=8
title 919
req 91 /api/books/9781000000009
req 91 /api/books/9781000000009/similar?count=8
done 250 /api/books/9781000000009
done 258 /api/books/9781000000009/similar?count=8
title 270
Without preloading the API requests start only after the chunk has downloaded, at 272 ms; with preloading at 91 ms. That is one round trip. But most of the difference comes after the API responses: without preloading the title appears about 470 ms after the responses arrive, with preloading 12 ms after. We did not break that time down in this post; the measured result is that having the chunk ready before the click also makes the first render noticeably faster. Preloading does not compete with the initial load: the Router requests the chunks after the first navigation has finished. A more selective strategy could load based on whether the link is visible or hovered; that is the SPA counterpart of the moderate logic in Speculation Rules.
Compression: Brotli Levels and zstd
In the second post we turned on ASP.NET Core's response compression and noted that Brotli's default level is the fastest. According to the documentation in the typings, the default of BrotliCompressionProviderOptions.Level is CompressionLevel.Fastest; in the dotnet/runtime source this maps to Brotli quality 1, Optimal to 4 and SmallestSize to 11. We wrote a small script that compares the levels on Bookstore's real responses (Node.js's zlib uses the same Brotli and zstd libraries; times are the median of 5 runs):
response raw gzip 6 br 1 br 4 br 11 zstd 3 zstd 19
chunk-C01pusRY.js 205.6 kB 68.4 kB 2.9ms 78.0 kB 0.6ms 69.8 kB 1.4ms 60.2 kB 182.4ms 71.7 kB 0.6ms 63.1 kB 24.3ms
book page (SSR HTML) 12.9 kB 3.8 kB 0.1ms 4.2 kB 0.0ms 3.7 kB 0.1ms 3.1 kB 9.2ms 3.9 kB 0.0ms 3.7 kB 1.6ms
/api/books (JSON) 534.8 kB 36.6 kB 1.5ms 58.1 kB 0.4ms 36.2 kB 0.8ms 13.2 kB 561.8ms 41.2 kB 0.3ms 14.7 kB 121.8ms
The table shows two things. For dynamic responses, quality 4 is much smaller than quality 1 and still cheap: the 535 KB book list becomes 36 KB instead of 58 KB, in under 1 ms. Quality 11 is for static files: the same JSON drops to 13 KB but takes half a second; it should be done once during the build, not on every request. zstd at fast levels is smaller than Brotli 1 but not as small as Brotli 4; at its highest level it is also behind Brotli 11 for these files. According to browser-compat-data, zstd has been in every engine since Chrome 123, Firefox 126 and Safari 26.3. ASP.NET Core 10's response compression has no zstd provider; the ASP.NET Core reference pack of .NET 11 contains ZstandardCompressionProvider.
In real responses the difference was even larger than in the table. With Fastest, ASP.NET Core sent /api/books as 160.6 KB on the network, almost three times the 58 KB of quality 1 compressed in one go: when JSON is written as a stream in pieces, the fastest level compresses much worse. The changes:
// Dynamic responses (JSON, server-rendered HTML) are compressed per request; static files come precompressed
builder.Services.AddResponseCompression(options =>
{
// Off by default for HTTPS (BREACH): these responses carry no secrets next to user input
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
// Default is Fastest (Brotli quality 1); Optimal is quality 4: much smaller JSON for little CPU
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Optimal);
// Runs after `ng build`: writes a Brotli (quality 11) copy next to every text file of the browser build.
// Bookstore.Api serves these instead of compressing on every request. zstd level 19 was larger than
// Brotli 11 for every file of this build (lab/compress-sizes.mjs), so no .zst copies.
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import zlib from 'node:zlib';
const root = 'dist/bookstore-web/browser';
const { BROTLI_PARAM_QUALITY, BROTLI_PARAM_MODE, BROTLI_MODE_TEXT } = zlib.constants;
const files = readdirSync(root, { recursive: true }).map(String).filter((f) => /\.(js|css|html|txt|svg|json)$/.test(f));
let raw = 0;
let br = 0;
for (const file of files) {
const path = join(root, file);
const input = readFileSync(path);
const brotli = zlib.brotliCompressSync(input, { params: { [BROTLI_PARAM_QUALITY]: 11, [BROTLI_PARAM_MODE]: BROTLI_MODE_TEXT } });
// A compressed copy that is not smaller is useless (tiny files)
if (brotli.length < input.length) writeFileSync(path + '.br', brotli);
raw += statSync(path).size;
br += Math.min(brotli.length, input.length);
}
console.log(`precompressed ${files.length} files: ${(raw / 1000).toFixed(1)} kB -> br ${(br / 1000).toFixed(1)} kB`);
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using Microsoft.Net.Http.Headers;
namespace Bookstore.Api.Compression;
// Serves main-ABC123.js.br (written by bookstore-web/tools/precompress.mjs at build time)
// instead of compressing the same file again on every request
public static partial class PrecompressedFiles
{
// Brotli 11 was smaller than zstd 19 for this build; a ("zstd", ".zst") entry would go here too
private static readonly (string Encoding, string Extension)[] Encodings = [("br", ".br")];
private static readonly FileExtensionContentTypeProvider ContentTypes = new();
// Before UseStaticFiles: picks the precompressed copy the browser accepts
public static IApplicationBuilder UsePrecompressedFiles(this IApplicationBuilder app, IFileProvider files) =>
app.Use(async (context, next) =>
{
string path = context.Request.Path.Value ?? "";
string acceptEncoding = context.Request.Headers.AcceptEncoding.ToString();
if (HttpMethods.IsGet(context.Request.Method) && ContentTypes.TryGetContentType(path, out _))
{
foreach ((string encoding, string extension) in Encodings)
{
// Simplified Accept-Encoding check (no q-values), enough for browsers
if (acceptEncoding.Contains(encoding, StringComparison.OrdinalIgnoreCase) && files.GetFileInfo(path + extension).Exists)
{
context.Request.Path = path + extension;
break;
}
}
}
await next();
});
public static StaticFileOptions StaticFileOptions()
{
FileExtensionContentTypeProvider contentTypes = new();
contentTypes.Mappings[".br"] = "application/octet-stream";
return new StaticFileOptions
{
ContentTypeProvider = contentTypes,
// Runs before the body is written, so response compression sees Content-Encoding and skips the file
OnPrepareResponse = context =>
{
string name = context.File.Name;
IHeaderDictionary headers = context.Context.Response.Headers;
foreach ((string encoding, string extension) in Encodings)
{
if (name.EndsWith(extension, StringComparison.Ordinal))
{
name = name[..^extension.Length];
headers.ContentEncoding = encoding;
headers.Vary = HeaderNames.AcceptEncoding;
context.Context.Response.ContentType = ContentTypes.TryGetContentType(name, out string? type) ? type : "application/octet-stream";
}
}
// main-PL3PRF3A.js: the name changes with the content, so the file never needs revalidation
headers.CacheControl = HashedName().IsMatch(name)
? "public, max-age=31536000, immutable"
: name.EndsWith(".html", StringComparison.Ordinal) ? "no-cache" : headers.CacheControl;
},
};
}
[GeneratedRegex(@"-[A-Za-z0-9_-]{8}\.(js|css)$")]
private static partial Regex HashedName();
}
app.UseResponseCompression();
// "/" -> index.html, the home page prerendered at build time; "/books/{isbn}" -> books/{isbn}/index.html
// for prerendered book pages, without a 301 redirect to "/books/{isbn}/" (one round trip less)
app.UseDefaultFiles(new DefaultFilesOptions { RedirectToAppendTrailingSlash = false });
app.UsePrecompressedFiles(app.Environment.WebRootFileProvider);
app.UseStaticFiles(PrecompressedFiles.StaticFileOptions());
The build script in package.json is now ng build && node tools/precompress.mjs. At first we also precompressed with zstd (level 19) and preferred it; the catalog page's total came out at 210.3 KB, versus 203.9 KB with Brotli 11. Since Brotli was smaller for every file of this build, we kept only the .br copies. The default of EnableForHttps is false: over HTTPS, compression opens the door to the BREACH attack when a secret value and text the user controls are in the same response. Bookstore's pages and JSON carry no token or CSRF key; if your app has them, do not compress those responses, or mask the secret on every request.
response enc wire(kB) decoded(kB) cache-control
/books br 1.4 2.1 -
/chunk-CPg4KbJp.js br 11.9 27.8 -
/chunk-CTSCdMNo.js br 11.0 28.6 -
/main-CVA4GSYC.js br 50.5 129.9 -
/chunk-DLdUTTQE.js br 87.0 205.6 -
/styles-CYMCJ46A.css br 0.9 1.6 -
/fonts/inter-latin-400-normal.woff2 - 24.0 23.7 -
/fonts/inter-latin-700-normal.woff2 - 24.7 24.4 -
/api/books br 160.6 534.8 -
bytes on the wire: 371.8 kB in 9 requests
response enc wire(kB) decoded(kB) cache-control
/books br 1.4 2.7 -
/chunk-C-tuJ80r.js br 8.9 27.8 public, max-age=31536000, immutable
/main-PL3PRF3A.js br 36.0 131.8 public, max-age=31536000, immutable
/chunk-C01pusRY.js br 60.5 205.6 public, max-age=31536000, immutable
/chunk-DWwqIwo2.js br 8.1 28.6 public, max-age=31536000, immutable
/styles-CYMCJ46A.css br 0.7 1.6 public, max-age=31536000, immutable
/fonts/inter-latin-400-normal.woff2 - 24.0 23.7 -
/fonts/inter-latin-700-normal.woff2 - 24.7 24.4 -
/chunk-CxcuijdK.js br 1.5 2.9 public, max-age=31536000, immutable
/api/books br 38.0 534.8 -
bytes on the wire: 203.9 kB in 10 requests
| Catalog page (phone profile, ms) | Before | After |
|---|---|---|
| Total on the network (KB) | 371.8 | 203.9 |
| FCP | 1332 | 1096 |
LCP (h1) | 1380 | 1148 |
| First book row visible | 2816 | 1604 |
The book list reaches the screen 1.2 s earlier; on this page the LCP element is the h1, so the LCP gain shows only part of it. The home page's LCP did not change (1444 and 1452 ms): there LCP depends on the download time of an AVIF image, which is not compressed.
immutable for Hashed Files
The OnPrepareResponse above gives files whose name changes with their content (main-PL3PRF3A.js) Cache-Control: public, max-age=31536000, immutable: the file is fresh for a year. immutable also tells Firefox and Safari not to revalidate the file even on reload; according to browser-compat-data, Chrome does not support this directive, and there max-age does the work. HTML is no-cache: it must be checked with the server every time, because it carries the new build's hashed file names. On the second visit only the page and the API requests go to the network:
response enc wire(kB) decoded(kB) cache-control
/books br 1.4 2.7 -
/chunk-C-tuJ80r.js br cache 27.8 public, max-age=31536000, immutable
/chunk-C01pusRY.js br cache 205.6 public, max-age=31536000, immutable
/main-PL3PRF3A.js br cache 131.8 public, max-age=31536000, immutable
/chunk-DWwqIwo2.js br cache 28.6 public, max-age=31536000, immutable
/styles-CYMCJ46A.css br cache 1.6 public, max-age=31536000, immutable
/fonts/inter-latin-400-normal.woff2 - cache 23.7 -
/fonts/inter-latin-700-normal.woff2 - cache 24.4 -
/chunk-CxcuijdK.js br cache 2.9 public, max-age=31536000, immutable
/api/books br 38.0 534.8 -
bytes on the wire: 39.5 kB in 2 requests
To be honest, the same second visit also came from the cache without Cache-Control: when there is no header, the browser computes an estimated freshness lifetime from Last-Modified. That estimate depends on the file's age and is very short right after a deploy; an explicit immutable does not leave the result to an estimate. The details of HTTP caching are in post 06 of the Web Servers and Hosting series.
Production Notes
Keep the transitions you measure apart in RUM: the navigationType values navigate, reload, back-forward, back-forward-cache and prerender are different experiences; putting them all into one p75 hides the effect of both bfcache and prerendering. Route transitions inside an SPA themselves do not show up in the current stable Core Web Vitals; the soft navigation support we discussed in the first post will make them measurable.
Speculation Rules mean extra requests to the server: with moderate eagerness every hover can trigger an SSR render. The Sec-Purpose header (prefetch;prerender) lets you separate speculative requests in logs and metrics. For compression, also check that the precompressed files make it into the deploy: if a .br file is missing, the middleware silently falls back to the raw file and dynamic compression; you will not notice without measuring. Compression dictionary transport, which uses the previous version of a file as a dictionary and sends only the difference, is Chromium-only for now (Chrome 130 according to browser-compat-data); it is worth a look for large bundles that are deployed often, and we did not try it in this post.
Common Pitfalls
- Adding Speculation Rules to an Angular SPA and assuming links with
routerLinkwill get faster too; the Router catches the click and the prepared page is thrown away. - Analytics and other side effects (cart, counters) running in a prerendered page; without a
document.prerenderingcheck the view is counted twice. - Not refreshing stale data in a page restored from bfcache; use
pageshowandevent.persisted. unloadand unnecessaryCache-Control: no-store; in Chrome they are often no longer blockers, in other browsers they still are.- Downloading the back office to every customer with
PreloadAllModules. - Leaving response compression at the default
Fastestlevel, or compressing static files again on every request. - Turning on
EnableForHttpsover HTTPS without checking whether responses contain secret values.
A Small Experiment
★☆☆ Change bfcache.mjs so that the book page leaves a WebSocket or an open fetch behind (for example a request that does not finish when the page is left). What do the CDP reason and notRestoredReasons say?
★★☆ Extend FlaggedPreloading to load routes whose link enters the viewport (an IntersectionObserver for every routerLink). Measure transitions between the home, catalog and book pages with speculation.mjs --no-rules, and compare the number of downloaded chunks with chunks.mjs.
★★★ Build Bookstore.Api with the .NET 11 SDK, add ZstandardCompressionProvider, and compare zstd with Brotli Optimal for /api/books by size on the network and server CPU time.
The in-app transition went from 810 ms to 207 ms, the book list in the catalog from 2.8 s to 1.6 s, and the back button takes 40 ms with bfcache. In the last post of the series we collect what changed in Core Web Vitals and in Angular's performance tooling from 2020 to today, and list the five things to check first in an old Angular project.