Skip to content

Script Loader: Prefetch assets for the next admin screen - #13084

Draft
westonruter wants to merge 19 commits into
WordPress:trunkfrom
westonruter:add/admin-script-style-preloading
Draft

westonruter wants to merge 19 commits into
WordPress:trunkfrom
westonruter:add/admin-script-style-preloading

Conversation

@westonruter

@westonruter westonruter commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

Explores one way to soften the cost of retiring script and style concatenation, per Core-57548: when the next screen can be predicted with confidence, prefetch what that screen is certain to need, so it is already in the HTTP cache by the time the user gets there.

This has changed substantially since the first revision, in response to the review on this PR. It used rel="preload" and was login-only; it now uses rel="prefetch", covers the editor as well, and from the login screen prefetches only what blocks the admin's first paint. All the measurements below were taken against the current implementation; the full method and raw numbers are in the benchmark comments on this PR.

What this does

Two contexts qualify today.

The login screen → the admin. With concatenation off, the first admin screen downloads each core script and stylesheet separately, which is what makes an uncached admin load slower than a concatenated one. The ones that block its first paint — the stylesheets, and the scripts printed in the head — are prefetched while the login form is on screen, putting them in the cache during the time the user spends typing credentials. 21 tags on a default install.

The Dashboard and the post list tables → the editor. The editor is the usual next stop from both, and it is by far the heaviest screen in the admin. 19 tags.

The two compose: a login whose redirect_to points at post-new.php, or at post.php?action=edit, prefetches both sets — 40 tags.

Handles the current screen has already printed are skipped, so each context only fetches what it actually adds.

Nothing is printed when concatenation is enabled; when the screen is not the login form (password reset, registration, logout, check-your-email); on an interim login; when redirect_to points outside the admin; on admin screens other than the Dashboard and post lists; for a user who cannot create the post type; or for a post type still using the classic editor.

Why: login → Dashboard, today's default vs this PR

Fresh browser context per run (empty cache), real login submitted 3 s after the login screen loaded, SCRIPT_DEBUG off, gzip and one-year static caching on, 10 runs per arm interleaved, medians with the full range in brackets. Three arms:

  • concat — today's default, concatenation on. The login screen prints no prefetch links.
  • none — concatenation off and nothing prefetched: what retiring concatenation would cost on its own.
  • prefetch — concatenation off, with this PR.
concat none prefetch
Fast 4G — LCP 1134 ms [1104–1164] 1412 ms [1360–1536] 840 ms [824–880]
Fast 4G — DOMContentLoaded 3596 ms 4105 ms 3650 ms
Fast 4G — load 3597 ms 4107 ms 3651 ms
Fast 4G — Dashboard requests / transferred 83 / 1,511 KB 118 / 1,456 KB 99 / 1,367 KB
Slow 4G — LCP 3494 ms [3464–3508] 3806 ms [3764–3836] 1872 ms [1852–1880]
Slow 4G — DOMContentLoaded 13286 ms 15207 ms 13302 ms
Slow 4G — load 13287 ms 15209 ms 13303 ms
Slow 4G — Dashboard requests / transferred 81 / 1,490 KB 116 / 1,437 KB 97 / 1,346 KB

Against today's default, this PR's configuration gives the Dashboard an LCP 294 ms (−26%) faster on Fast 4G and 1,622 ms (−46%) faster on Slow 4G, with the three arms' ranges well separated, while DOMContentLoaded and load stay within 1.5% (+54 ms and +16 ms). Retiring concatenation without prefetching would cost 278–312 ms of LCP and 0.5–1.9 s of DOMContentLoaded.

Prefetching overtakes concatenation on LCP largely because concatenation defeats caching across screens. The login screen concatenates too when the constant is on, but into a bundle of its own (dashicons,buttons,forms,l10n,wp-base-styles,wp-tooltip,login), so the Dashboard's bundles share nothing with it and download from scratch. With concatenation off, the Dashboard reuses the six files the login screen already loaded plus the 21 prefetched ones. Some of the lead belongs to prefetching as such, though: a concatenated setup could in principle prefetch its own bundle URLs, which differ per screen; that has not been measured.

The login screen itself pays for concatenation being off, not for the prefetching: its load goes from 698 to 912 ms on Fast 4G and from 2418 to 3181 ms on Slow 4G, the same as the no-prefetch arm (920 and 3195 ms). Its FCP barely moves (+52 ms on Fast 4G, −80 ms on Slow 4G).

In current Chrome, "Slow 4G" is the preset formerly named "Fast 3G"; the prefetch arm reproduced the earlier Fast 3G runs to within 12 ms.

Render-blocking only, from the login screen

The login set used to be whatever load-scripts.php and load-styles.php bundle, but being concatenated was only a stand-in for what matters. With concatenation off, three of the six bundled scripts (hoverIntent, wp-dom-ready, wp-hooks) print in the footer, and Chrome marks only the stylesheets and the three head scripts (jquery-core, jquery-migrate, utils) as render-blocking on the Dashboard. Dropping the three footer scripts made no measurable difference to anything.

Prefetching the footer scripts as well was benchmarked, since they do block DOMContentLoaded. When the prefetch finishes before the login is submitted the gain is large — DOMContentLoaded 3.6 s → 1.05 s on Fast 4G, LCP unchanged — but the set is about 1.2 MB gzipped, mostly the command palette's dependencies, and on Fast 3G it had not finished 3 s or even 8 s after the login screen loaded. What was still in flight carried across the navigation and competed with the Dashboard's render-blocking stylesheets, costing 574 ms and 1,038 ms of LCP respectively. Plain prefetch links cannot tell those cases apart, so the footer is left out.

Stylesheets only, for the editor

The editor's incremental cost over the Dashboard is 47 files. Split by type:

Subset Handles Gzipped Raw
CSS 17 97.6 KB 634.8 KB
JS 30 1,261.8 KB 4,184.6 KB
Everything 47 1,359.4 KB 4,819.5 KB

Only the stylesheets are prefetched. wp-editor JS alone is 503 KB gzipped and wp-block-library another 356 KB; over a megabyte of speculative download for a screen the user may never open is not a reasonable default, especially on a metered connection. The stylesheets are render-blocking and land in the same size class as the login screen's own prefetch. Prefetching the editor's scripts on an explicit intent signal — hover or focus on an Add New link — would be a reasonable follow-up, where the prediction is strong enough to justify the bytes.

These figures predate wp-editor gaining a dependency on the wp-media-utils stylesheet, which the root expansion picked up without a code change.

Implementation

In src/wp-includes/script-loader.php:

  • wp_prefetch_admin_assets() — decides whether a next screen can be predicted, builds the list and prints it. Hooked to login_head and admin_head at the default priority, after the current screen's own assets have printed so the already-printed check works.
  • _wp_expand_dependency_handles() — expands root handles to include everything they depend on. Typed against WP_Dependencies, since it reads only registered and deps.
  • _wp_resolve_dependency_urls() — resolves a registered handle to the URL it would load from, mirroring WP_Scripts::do_item() and WP_Styles::do_item(): the version argument, the script_loader_src and style_loader_src filters, and the RTL replace-or-append rules. Prints nothing, does not touch the queue. Deliberately typed WP_Scripts|WP_Styles rather than WP_Dependencies: it needs members only those two declare, and Gutenberg's WP_Fonts is a third subclass that declares none of them.

The editor set is expressed as 8 roots, so it follows the dependencies declared in wp_default_styles() rather than restating them — wp-edit-post alone accounts for most of the editor chrome. The admin-wide set is still a flat list.

The filter is prefetch_admin_assets, since it is no longer login-specific. It is modeled on wp_preload_resources: it receives a plain list of attribute arrays, duplicates by href are collapsed after the filter runs with the first entry winning, and as takes any destination, not just script and style. Its second argument is the URL of the screen being prefetched for.

Reuse across the navigation

Verified for prefetch in Chrome, fresh isolated browser context per run, Fast 4G, reading Resource Timing and the network log on the Dashboard after a real login submit:

Login submitted Static-file caching headers Served from cache, no request Revalidated (304)
~4 s after the prefetch Last-Modified and ETag only 24 / 24 0
241 s after the prefetch Last-Modified and ETag only 6 / 24 18
241 s after the prefetch Cache-Control: max-age=31536000 21 / 21 0

Reuse is governed by ordinary HTTP freshness. Without explicit caching headers the browser estimates freshness as a tenth of the time since Last-Modified. The 18 that revalidated at 241 s were exactly the files rebuilt minutes earlier, whose estimated freshness had run out; the six still served from cache were old enough to stay fresh for hours or days. No five-minute exemption for unused prefetches applied. A 304 still saves the download, but costs a round trip.

A freshly built development checkout is close to the worst case for this. On a production site core's files are typically unchanged since the last update, which gives a heuristic lifetime of days, and many hosts send an explicit long max-age for CSS and JS besides. Far-future caching headers are not required for this to work — see the caching comment on this PR for the conditions and the HTTP Archive data.

What still needs measuring

  1. The editor case end to end. Dashboard → Add New Post has not been benchmarked; only the asset sets and payload sizes are measured.
  2. Repeat logins with a warm cache, where both configurations should converge.
  3. Anything other than Chrome, HTTP/1.1 and localhost. Over HTTP/2 the request-count effects shrink, which should narrow the gap between the arms.

Corrections to earlier descriptions

The first revision claimed no "preloaded but not used" console warnings appeared. That was wrong — the check used a tool that surfaces JS console.* calls but not browser-generated warnings, so it could not have observed them. Thanks to @manzoorwanijk for catching it. The switch to prefetch moots the warnings, but the claim should not have been made.

A later revision also argued that preload, unlike prefetch, would make reuse depend on static-file cache headers core does not control. That was wrong too: prefetch depends on them in exactly the same way, as the reuse test above shows. The docblock has been corrected to match.

Earlier revisions also said the login screen never concatenates even with the constant on. That was too strong: it depends on whether script_concat_settings() runs before login_init, as explained under "The gate is a prediction" below, and on the setup used for the benchmarks above it does concatenate.

Design decisions

prefetch, not preload. These are resources for the next navigation, which is what prefetch describes. Preload fetches at the current document's priority and warns about resources the document never uses.

No fetchpriority. A prefetch is already dispatched at the lowest priority. as is kept — it gives the request the same destination the next screen will ask for, which is what lets the response be reused.

In the head, not the footer. prefetch is body-ok so the footer would be valid, but the cost is small and already downstream of the critical path: when measured, the login screen's 24 tags then added 253 bytes gzipped and the Dashboard's 18 added 222 bytes. Hook priority puts them after the current screen's own render-blocking CSS, so the preload scanner has found everything render-blocking before reaching a prefetch byte. Footer placement would move ~200 bytes out of a position already behind the critical path, at the cost of a later prefetch start.

The admin-wide list does not vary by destination. Nearly all of it is universal admin CSS rather than Dashboard CSS: wp-admin is an alias handle enqueued on every admin screen that pulls in dashboard, edit, themes, nav-menus, widgets, revisions and the rest. Checked across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: the head scripts and 24 of the original 25 styles appear on every one. site-health was the sole exception and was dropped.

The gate is a prediction, not a reading. $concatenate_scripts cannot be relied on from the login screen: if script_concat_settings() runs before login_init fires — registering a script on init is enough to trigger it — it evaluates is_admin() as false and settles the global on false whatever the constant says, and the login screen then does not concatenate either. This gates on CONCATENATE_SCRIPTS && ! SCRIPT_DEBUG instead. That prediction can be wrong if a plugin pre-sets the global or defines the constant only when is_admin(). The underlying quirk looks worth its own ticket.

Known gaps

  • use_block_editor_for_post_type() only exists in the admin, so the login-screen path cannot make that check. A classic-editor site with redirect_to=post-new.php will still prefetch editor CSS. Confirmed on a site running the Classic Editor plugin, where the Dashboard correctly prints nothing but that login URL still prints the editor stylesheets.
  • The login screen cannot check capabilities either, since no user is authenticated yet; it relies on redirect_to alone.
  • The admin color scheme (colors) is universal and render-blocking but deliberately not prefetched from the login screen, since the scheme is a per-user setting and the user is unknown at that point. It is the only render-blocking core stylesheet on every admin screen that is not covered. Same class of problem as the locale mismatch below.
  • Footer scripts are not prefetched, per "Render-blocking only" above. Getting their DOMContentLoaded gain without the slow-connection LCP cost would need the prefetch to be abandoned when the login form is submitted — for example fetch() with an AbortController rather than link tags — which has not been tried.

Review findings

Addressed: switched to prefetch (2); restricted to the login form action and interim login (3, partly — Save-Data is still not honored); dropping the script handles (4, partly — the three footer scripts are gone, the three head scripts stay because they block rendering); dropped the screen-specific handle; derived the editor list from roots rather than hardcoding it (5, partly — the admin-wide list is still flat); narrowed the filter's documented contract to the attributes actually printed (11); Fast 3G / Slow 4G.

Not addressed: framing this as a complement rather than a replacement (1) — agreed, and it should not be the argument for retiring load-styles.php; a drift test comparing the lists against what the admin actually loads (5); locale mismatch between the login screen and the admin (6); args/#fragment in the script branch of the resolver (7); a docblock note that the src filters run in a logged-out, non-admin request (8); idempotency (12). No automated tests yet.

Testing instructions

With CONCATENATE_SCRIPTS false and SCRIPT_DEBUG false, and the block editor enabled for posts and pages, view source and count <link rel="prefetch"> tags:

Screen Expected
wp-login.php 21
wp-login.php?redirect_to=%2Fwp-admin%2Fpost-new.php 40
wp-login.php?redirect_to=%2Fwp-admin%2Fpost.php%3Fpost%3D1%26action%3Dtrash 21
wp-login.php?redirect_to=%2Fhello-world%2F 0
wp-login.php?action=lostpassword, ?interim-login=1 0
Dashboard, Posts list, Pages list 19
post-new.php, Plugins, Settings, Media 0
Anything, with CONCATENATE_SCRIPTS true 0

With the Classic Editor plugin active, the Dashboard and list tables print 0.

To check reuse, load the login screen, log in within a few seconds, and confirm in the Network panel that the prefetched URLs are served from the cache on the Dashboard without a request. Waiting a few minutes before logging in on a freshly built checkout will show 304s instead, per "Reuse across the navigation".

Trac ticket: https://core.trac.wordpress.org/ticket/57548

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5, Claude Opus 5.5
Used for: Running the benchmarks and asset analysis, drafting the implementation, and drafting this description. The approach, the design decisions and the final code were reviewed and edited by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

When script and style concatenation is disabled, the first admin screen after
logging in downloads each core script and stylesheet separately. Measured on a
throttled Fast 4G connection with a cold cache, that costs roughly 600 ms of
First Contentful Paint against the concatenated equivalent: 28 extra requests
that HTTP/1.1 has to serialize behind its six-connection cap.

Print `link rel=preload` tags on the login screen for the handles that
`load-scripts.php` and `load-styles.php` would otherwise bundle, so the browser
puts them in the HTTP cache while the login form is on screen rather than after
the redirect. The tags carry `fetchpriority=low` so they queue behind the login
screen's own render-blocking assets, and handles the login screen has already
printed are skipped.

Add `_wp_resolve_dependency_urls()` to resolve a registered handle to the URL it
would load from, mirroring how `WP_Scripts::do_item()` and `WP_Styles::do_item()`
build it — the version argument, the `script_loader_src` and `style_loader_src`
filters, and the RTL replace-or-append rules — without printing anything or
disturbing the queue.

Gate on `CONCATENATE_SCRIPTS && ! SCRIPT_DEBUG` rather than on the
`$concatenate_scripts` global. `script_concat_settings()` usually runs on a login
request before `login_init` fires, since registering any script on `init` is
enough to trigger it, and at that point it evaluates `is_admin()` as false and
settles the global on false whatever the constant says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@manzoorwanijk

Copy link
Copy Markdown
Member

Claude Code analysis of the change

wp-admin script/style concatenation: phase 1 findings (HTTP/1.1, /wp-admin/ Dashboard)

Measured on 2026-08-16 against the local Docker env (nginx:alpine, HTTP/1.1, LOCAL_DIR=build, minified assets, SCRIPT_DEBUG=false) with the Chrome DevTools MCP server.

Summary

  • Cold loads (cache bypassed) on a throttled connection are where concatenation matters: removing it makes Dashboard FCP/LCP go from 616 ms to 1304 ms with gzip on (+688 ms, +112%) and from 1296 ms to 1484 ms with gzip off (+188 ms, +15%).
  • With a primed browser cache the difference is noise: +12 ms (+5%) with gzip on, within a few ms with gzip off.
  • Unthrottled on localhost, concat off is marginally faster (160 ms vs 172 ms), i.e. the PHP cost of load-styles.php / load-scripts.php outweighs the request savings when latency is near zero.
  • The whole effect comes from CSS: concat collapses 26 render-blocking stylesheets into one load-styles.php request. Scripts are barely concatenated on the Dashboard (only 6 handles in 2 load-scripts.php bundles; the other ~80 scripts load individually in both modes because they carry inline data or translations).
  • FCP and LCP were identical (or within 30 ms) in every run; the LCP element is the "Welcome" H2 heading, so both metrics are gated by render-blocking CSS.

Results

Fast 4G is Chrome DevTools' built-in preset applied via CDP Network.emulateNetworkConditions. N = 10 recorded runs per cell after 2 unrecorded warm-ups. IQR is p25 to p75.

Throttle gzip Cache Concat N Median FCP (ms) FCP IQR Median LCP (ms) LCP IQR Requests Transfer
Fast 4G on bypassed on 10 616 612 to 619 616 612 to 619 89 1354 KB
Fast 4G on bypassed off 10 1304 1300 to 1307 1304 1300 to 1307 117 1375 KB
Fast 4G on primed on 10 252 248 to 260 252 248 to 260 90 1 KB
Fast 4G on primed off 10 264 261 to 264 264 261 to 264 118 1 KB
Fast 4G off bypassed on 10 1296 1293 to 1296 1296 1293 to 1296 89 4360 KB
Fast 4G off bypassed off 10 1484 1481 to 1487 1484 1481 to 1487 117 4369 KB
Fast 4G off primed on 10 260 256 to 260 276 273 to 280 90 3 KB
Fast 4G off primed off 10 256 256 to 260 282 280 to 284 118 3 KB
None on bypassed on 10 172 172 to 175 172 172 to 175 89 1354 KB
None on bypassed off 10 160 153 to 163 160 153 to 163 117 1375 KB

Delta of "concat off" versus "concat on" (positive = removing concat is slower):

Throttle gzip Cache FCP on FCP off Delta ms Delta % LCP on LCP off Delta ms Delta %
Fast 4G on bypassed 616 1304 +688 +112% 616 1304 +688 +112%
Fast 4G on primed 252 264 +12 +5% 252 264 +12 +5%
Fast 4G off bypassed 1296 1484 +188 +15% 1296 1484 +188 +15%
Fast 4G off primed 260 256 -4 -2% 276 282 +6 +2%
None on bypassed 172 160 -12 -7% 172 160 -12 -7%

Request shape per condition (from the network log):

  • Concat on: 3 stylesheet requests (1 load-styles.php bundling 26 handles, thickbox.css, colors.min.css) plus editor.min.css, and 2 load-scripts.php bundles (jquery-core,jquery-migrate,utils and hoverIntent,wp-dom-ready,wp-hooks) plus ~80 individual scripts.
  • Concat off: 28 stylesheet requests and 84 script requests, all individual files.
  • Transferred bytes are essentially the same in both modes (1354 vs 1375 KB gzipped, 4360 vs 4369 KB raw); the difference is request count and HTTP/1.1 connection queuing, not payload.

Raw per-run values (ms)

  • Fast 4G, gzip on, bypassed, concat on: FCP 620, 612, 612, 616, 624, 616, 612, 616, 620, 612; LCP same as FCP
  • Fast 4G, gzip on, bypassed, concat off: FCP 1304, 1296, 1304, 1300, 1308, 1312, 1300, 1300, 1304, 1308; LCP same as FCP
  • Fast 4G, gzip on, primed, concat on: FCP 248, 260, 248, 260, 252, 268, 340, 252, 244, 248; LCP same as FCP
  • Fast 4G, gzip on, primed, concat off: FCP 264, 252, 264, 264, 268, 264, 264, 260, 264, 256; LCP same as FCP
  • Fast 4G, gzip off, bypassed, concat on: FCP 1296, 1296, 1288, 1296, 1292, 1300, 1292, 1296, 1300, 1296; LCP same as FCP
  • Fast 4G, gzip off, bypassed, concat off: FCP 1484, 1484, 1488, 1480, 1484, 1480, 1476, 1484, 1488, 1488; LCP same as FCP
  • Fast 4G, gzip off, primed, concat on: FCP 260, 264, 260, 256, 256, 260, 256, 260, 260, 256; LCP 276, 280, 284, 272, 272, 276, 280, 276, 288, 272
  • Fast 4G, gzip off, primed, concat off: FCP 256, 260, 264, 256, 260, 256, 252, 256, 260, 256; LCP 272, 284, 280, 280, 284, 284, 280, 284, 284, 280
  • Unthrottled, gzip on, bypassed, concat on: FCP 172, 168, 168, 176, 172, 172, 176, 172, 172, 196; LCP same as FCP
  • Unthrottled, gzip on, bypassed, concat off: FCP 164, 164, 152, 160, 156, 160, 160, 148, 152, 164; LCP same as FCP

Method

  • Fixture: trunk at 3150f656e2 (includes the gzip toggle from PR Build/Test Tools: Enable text compression in the local Docker environment #12529), npm run build, .env with LOCAL_DIR=build and LOCAL_NGINX_COMPRESSION=on|off, env restarted on every gzip switch and verified with curl -I (Content-Encoding: gzip present or absent).
  • Toggle: wp-config.php defines SCRIPT_DEBUG and CONCATENATE_SCRIPTS from $_GET['script_debug'] / $_GET['enable_concat'] (exact true/false strings; defaults false/true), sets WP_DEBUG_DISPLAY false and DISABLE_WP_CRON true. URLs measured: /wp-admin/?enable_concat=true&script_debug=false and /wp-admin/?enable_concat=false&script_debug=false.
  • Static asset caching: the local nginx template got a location ~* \.(js|css)$ { expires 1y; add_header Cache-Control "public"; } block so individual assets are cacheable like on production hosts (otherwise the primed comparison is unfairly tilted toward load-*.php, which sends its own 1 year max-age).
  • Browser: dedicated isolated Chrome context, logged in as admin, viewport 1440x900, no extensions, tab in foreground, no interaction during loads.
  • Cache bypassed: CDP hard reload (Page.reload with ignoreCache), which refetches every subresource (verified: 114 of 117 resources with non-zero transferSize each run). Primed: one priming load, then soft reloads; verified all subresources served from cache (transferSize 0).
  • Metrics: read in-page after load + 1.5 s settle via performance.getEntriesByName('first-contentful-paint') and a buffered largest-contentful-paint PerformanceObserver (last entry), recorded per run, median and IQR computed offline. Raw JSON per cell is in the session scratchpad results/ folder.
  • Preflight per condition confirmed the presence/absence of load-scripts.php / load-styles.php, request counts, .min assets and Content-Encoding.

Caveats

  • Run-to-run spread is tiny (IQR 3 to 8 ms) because DevTools throttling is a deterministic simulation on top of a localhost server. Real networks will be noisier; the direction and magnitude of the cold-load penalty on HTTP/1.1 is what to take from this, not the exact ms.
  • "Cache bypassed" reuses warm TCP connections across reloads and is a repeated-navigation scenario, not a true first visit (no DNS/TCP handshake cost is included). A real first visit would make the concat-off penalty on HTTP/1.1 slightly larger.
  • Only the Dashboard was measured. Screens with larger CSS/JS bundles (post editor: 36 style handles, customizer) will show a bigger cold-load gap.
  • Chrome's Fast 4G preset values are what the current Chrome build ships; the observed document TTFB under throttling was ~100 ms.

Interpretation for the removal decision

  • Removing concatenation with no replacement is a clear regression for cold loads on HTTP/1.1: roughly 2x FCP/LCP on the Dashboard with gzip, ~15% without gzip. Returning visitors with a warm cache are unaffected.
  • The regression is entirely about the number of render-blocking CSS requests. Any replacement only needs to address CSS delivery on first load (fewer stylesheet requests, preload, or 103 Early Hints); the script side is already effectively unconcatenated on this screen.
  • HTTP/2 and HTTP/3 (phase 2) should shrink this gap substantially since request multiplexing removes the 6-connection HTTP/1.1 limit; that measurement is the next step before deciding.

Environment state left in place (revert notes)

These local changes are still applied so phase 2 can continue; none are committed:

  • .env: LOCAL_DIR=build, LOCAL_NGINX_COMPRESSION=on (was src, unset). Revert and npm run env:restart to get back to the normal dev setup.
  • wp-config.php: SCRIPT_DEBUG / CONCATENATE_SCRIPTS query toggles, WP_DEBUG_DISPLAY false, DISABLE_WP_CRON true (was SCRIPT_DEBUG true, WP_DEBUG_DISPLAY true).
  • tools/local-env/default.template: the expires 1y block for .js/.css (shows in git diff).
  • concat-perf-strategy.md and this file are untracked in the repo root.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Comment thread src/wp-includes/default-filters.php Outdated
add_action( 'login_head', 'wp_resource_hints', 8 );
add_action( 'login_head', 'wp_print_head_scripts', 9 );
add_action( 'login_head', 'print_admin_styles', 9 );
add_action( 'login_head', 'wp_preload_admin_assets', 10 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be useful to print the preload links in login_footer. This would save some bytes before the body can load, especially if the list is long.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked Claude and it suggests not moving it:

Real numbers, and they cut against moving it — though your mechanism is genuinely there.

The response is chunked, so your premise holds: Transfer-Encoding: chunked, Content-Encoding: gzip. Bytes in the head do sit in front of everything after them.

But the cost is 253 bytes gzipped.

raw gzip
Login HTML without prefetch links 10,492 3,476
Login HTML with prefetch links 13,383 3,729
Cost of the 24 tags +2,891 +253

2.8 KB raw compresses to 253 bytes because the tags are near-identical — same scheme, host, path prefix, same ver string, same as value. The whole login document is 3.7 KB gzipped, comfortably inside a single initial congestion window, so there's no segment boundary for those 253 bytes to push anything past.

And they're already behind the render-blocking CSS. The hook priority does the work — print_admin_styles at 9, this at 10:

first stylesheet : line 7
last  stylesheet : line 14
first prefetch   : line 15
</head>          : line 41

All 8 stylesheets are emitted before the first prefetch tag, so the preload scanner has discovered every render-blocking resource before it reaches a single prefetch byte. Moving them to the footer would relocate 253 bytes from a position that's already downstream of the critical path.

The measurement I have is a worst case. Login FCP was 568 ms with the links vs 570 ms without — and that was with 25 rel=preload tags, which are bigger (they carried fetchpriority='low') and fetch at document priority rather than idle. The version we now ship is strictly less aggressive on both axes than the thing that measured as free.

Where you'd be right and I'd change position: if the list grew substantially, if a site's head were already large enough that boundaries mattered, or if someone filtered in many more resources via login_prefetch_admin_assets. None of those is the default case, but none is exotic either.

So I'd keep it in login_head. If you want the bytes out regardless, footer is valid markup and I'll move it — it just trades 253 bytes of head for a later prefetch start, and start time is what determines whether the prefetch finishes before the user submits.

Comment thread src/wp-includes/script-loader.php Outdated
@manzoorwanijk

manzoorwanijk commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

Combined review of Claude and Codex

Preload the admin's unconcatenated assets from the login screen

Reviewed against concat-perf-findings.md and concat-perf-strategy.md (HTTP/1.1, Dashboard, Fast 4G, local Docker env, LOCAL_DIR=build, gzip on). The PR branch was checked out and its two PHP files were copied into build/ for testing; the copies were reverted afterwards. Codex reviewed the same diff independently; its findings are folded in and attributed below.

Verdict

The mechanism works as described and the measured effect on the first Dashboard load after login is large. It is not a general answer to removing concatenation: it only helps a cold cache that happens to pass through wp-login.php, uses rel="preload" for a next-navigation resource (which Chrome does warn about, contrary to the PR description), and pushes 25 speculative requests onto every login-family screen. Worth continuing as a complementary optimization if the handle list is derived rather than hardcoded and the semantics are switched to prefetch, but it should not be the argument for retiring load-styles.php.

What was verified

  • Handle lists match exactly what concatenation produces on the Dashboard today: the 25 style handles are the two load-styles.php chunks (wp-pointer is split across the chunk boundary, which is why the findings doc counted 26) and the 6 script handles are the two load-scripts.php bundles.
  • With CONCATENATE_SCRIPTS=false, SCRIPT_DEBUG=false: 25 <link rel='preload'> tags after the 7 login stylesheets, none with concat on. All 25 URLs match Dashboard request URLs byte for byte, and after login all 31 preloaded assets (including the 6 shared with login) are served with transferSize 0.
  • Effect on the first Dashboard load after login, Fast 4G, cold cache, concat off: FCP 1128 ms (trunk) vs 572 ms (PR). For reference the findings doc has concat on at 616 ms and concat off at 1304 ms on a hard reload, so the PR recovers the whole FCP gap for this one path. load went 3738 ms to 3052 ms; 41 vs 20 of 118 resources from cache.
  • Cost on the login screen itself, Fast 4G, cold cache: FCP 572 ms (trunk) vs 580 ms (PR), so render is not hurt. load event moved from 900 ms to 1475 ms and resource count 23 to 44. Extra transfer is 129 KB gzipped (557 KB raw), about 2.6x the login page's own CSS payload.
  • Chrome logs 21 "was preloaded using link preload but not used within a few seconds from the window's load event" warnings on the login screen (every preload except jquery-core, jquery-migrate, wp-dom-ready, wp-hooks, which the login page consumes itself). The PR description says no such warnings appeared; that is not what a default install shows.

Findings

Design

  1. Scope of the win is narrow. It only helps a cold cache reached via wp-login.php. Cold-cache admin loads that never see the login screen are common and arguably more common for logged-in users: every core update changes every ver string, plugin updates change theirs, and remember-me cookies last 14 days. Those paths still pay the full concat-off penalty from the findings doc (+688 ms FCP on HTTP/1.1 with gzip). The PR should be framed as a complement, not a replacement.
  2. preload vs prefetch. The resources are for the next navigation, which is what prefetch means. Consequences of using preload here: unused-preload console warnings on every login page load (verified), preload priority semantics rather than idle-time semantics, and cross-navigation reuse depends entirely on the static files' HTTP cache headers, which core does not control. On hosts with no Cache-Control/Expires on .css/.js, freshly deployed files get short heuristic freshness and the Dashboard will revalidate each one (304 per file, still one HTTP/1.1 round trip each). Chrome keeps prefetch responses reusable for 5 minutes regardless of cacheability, which fits this use case better. This needs a browser-by-browser check either way (Codex raises the same point).
  3. Runs on every login_head, not just the login form: lostpassword, resetpass, register, logout confirmation, interim-login, failed logins. Speculatively fetching 129 KB gzipped for a user who lands on "check your email" is wasted, and Save-Data is not honored (Codex).
  4. Scripts are not worth preloading. Both the findings doc and the PR's own numbers show the gap is CSS. Dropping the 6 script handles removes the custom script URL resolver (and finding 6 below) for negligible loss.
  5. Hardcoded list will drift. Nothing ties it to what WP_Styles::do_item() actually concatenates. site-health is capability-gated on the Dashboard, admin-bar can be disabled, wp-auth-check can be filtered off (Codex). A more robust shape: register the list next to the wp-admin alias in wp_default_styles(), or derive it from the wp-admin handle's dependency tree plus a small explicit set, and add a test that compares it against the concat output on the Dashboard.
  6. Locale mismatch. The login screen resolves URLs with the site (or wp_lang) locale; the admin uses the user locale. An RTL user on an LTR site preloads the LTR files and then loads the RTL ones. Edge case, but it is silent waste.

Correctness in _wp_resolve_dependency_urls()

  1. Script branch drops $dependencies->args[ $handle ] and the #fragment handling that WP_Scripts::do_item() has (class-wp-scripts.php around the $added_args block). No core handle in the list uses either, but the helper claims to mirror do_item() and a plugin passing handle?arg would preload a URL that never gets requested. Goes away if scripts are dropped (finding 4).
  2. script_loader_src / style_loader_src run in a logged-out, non-admin request. Filters that branch on is_admin(), screen or user (CDN rewriters, per-user asset URLs) can produce a URL that differs from the one the admin request will load, defeating the cache hit. Also 31 extra filter invocations on an unauthenticated page (Codex). Worth a sentence in the docblock at least.
  3. Gate: script_concat_settings() honors a pre-set $concatenate_scripts global and plugins can define CONCATENATE_SCRIPTS only when is_admin(); the constant-only prediction can be wrong in both directions (Codex). Acceptable given the login-screen quirk the PR describes, but the docblock should say the gate is a prediction.
  4. Double escaping is harmless: _css_href() already returns esc_url() output and esc_url() is idempotent (verified), but the RTL branch string-replaces on an escaped URL exactly like do_item() does, so it is at least consistent.
  5. Filter contract: the docblock says the login_preload_admin_assets filter takes the same shape as wp_preload_resources, but the printer only emits href, as, fetchpriority; crossorigin, type, media are dropped (Codex). Either print the same attribute set as wp_preload_resources() or narrow the docblock.
  6. Not idempotent: calling wp_preload_admin_assets() twice prints the tags twice (Codex). Minor.

Verified as fine

  • Hook priority 10 after print_admin_styles/wp_print_head_scripts at 9, so the done check correctly skips the 6 login-shared handles.
  • RTL replace/append logic matches WP_Styles::do_item(); core admin styles all use replace with a suffix.
  • Escaping of filtered output (esc_url, esc_attr, type checks) is fine.
  • Login FCP is not regressed by the extra requests on Chrome with fetchpriority="low" (measured +8 ms).

Suggested direction

  • Keep the idea, switch to rel="prefetch" (or measure both across Chrome, Firefox, Safari with and without static cache headers before deciding).
  • Styles only; drop the script preloads and the script resolver.
  • Restrict to the login form action, skip Save-Data, skip interim-login.
  • Derive the handle list or add a test that fails when it drifts from the Dashboard concat output.
  • Re-benchmark the login screen on Fast 3G / mobile, since the cost is bandwidth rather than render.
  • Do not use this as the basis for removing concatenation; the phase 2 HTTP/2 and HTTP/3 measurement in the strategy doc is still the missing input.

Codex review (verbatim summary of its verdict)

"Overall verdict: not a sound approach. The URL resolver has fixable correctness gaps, but the larger problem is architectural: preload is being used for a future navigation and forces a sizable speculative download on every login-family screen. A CSS-only concatenation strategy, or admin-response Early Hints where supported, is substantially safer."

Where I differ from Codex: it rated the login-screen cost as blocking; measured, login FCP is unaffected and the cost is bandwidth plus a later load event, so I would rate it important rather than blocking. Its wp_installing() point does not apply (the login screen is not shown during install). Its architectural point stands. Full Codex output: session 01a00c8d-755c-7f70-bd92-98cecb0b185c (codex resume 01a00c8d-755c-7f70-bd92-98cecb0b185c).

@haqadn

haqadn commented Aug 16, 2026

Copy link
Copy Markdown
image

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Claude analysis of benefit of preloading

Dashboard load after logging in (10 runs per arm, fresh cache each run)

Metric No preload (control) With preload Δ
FCP 1256 ms 714 ms −542 ms (−43%)
LCP 1256 ms 714 ms −542 ms (−43%)
DOMContentLoaded 3880 ms 3252 ms −628 ms (−16%)
Load event 4096 ms 3466 ms −630 ms (−15%)
Login POST + 302 291 ms 293 ms +2 ms
Requests 123 123 0
Served from cache 21 42 +21
Transferred 1366.9 KB 1271.9 KB −95.0 KB

Distributions don't overlap at all — 1244–1280 ms vs 688–728 ms.

That's 542 of the original 602 ms concat-on/off FCP gap, or 90%.

Subtracting the login POST + redirect (~292 ms, present in both arms) to compare against the earlier direct-navigation numbers:

Scenario FCP
Concat ON, cold, direct nav (earlier) 740 ms
Concat OFF + preload, redirect-adjusted 421 ms
Concat OFF, no preload, redirect-adjusted 965 ms
Concat OFF, cold, direct nav (earlier) 1342 ms

Preloading doesn't just close the gap — it beats concatenation, because the bytes move during idle time on the login screen instead of during the dashboard load.

Cost to the login screen

Metric No preload With preload Δ
Login FCP 570 ms 568 ms −2 ms
Login load event 908 ms 1597 ms +689 ms
Last preload finished — 1570 ms —

fetchpriority="low" is doing its job: the login screen's own paint is untouched. What does move is the load event, +689 ms, because it waits on the preloads. Worth knowing if anything hooks window.onload there.

Two caveats on reading this

Why I ran a control arm despite you saying not to re-test. The earlier cold-cache number (1342 ms) was a hard reload of the dashboard, not a login→dashboard navigation. Going through the login screen warms 21 shared assets by itself, so that flow lands at 965 ms even with zero preloads. Without the control, preloading would have looked like it recovered 628 ms when the honest figure is 542 ms.

Dwell time. Preloads finish at ~1570 ms; the runs used a fixed 4-second dwell on the login screen, identical in both arms. A user whose password manager submits in under ~1.6 s gets proportionally less. Everything is still HTTP/1.1, so the whole effect should shrink over HTTP/2.

westonruter and others added 3 commits August 16, 2026 15:28
The assets these links point at are for the navigation that follows the login,
not for the login screen itself, and `rel="prefetch"` is what describes that.
Using `rel="preload"` had three consequences worth avoiding: it fetches at the
current document's priority rather than idle priority, it makes cross-navigation
reuse depend entirely on the static files' HTTP cache headers, which core does
not control, and it makes browsers warn about every preloaded resource the
document never goes on to use.

Rename `wp_preload_admin_assets()` to `wp_prefetch_admin_assets()` and the
`login_preload_admin_assets` filter to `login_prefetch_admin_assets` to match.
Keep the `as` attribute, which is what lets a prefetched response be reused for a
request with the same destination, and keep `fetchpriority="low"`.

Also narrow the filter's documented contract. It claimed to accept the same
resource attributes as the `wp_preload_resources` filter, but only `href`, `as`
and `fetchpriority` are ever printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A prefetch is already dispatched at the browser's lowest priority, so
`fetchpriority="low"` has nothing left to lower. The attribute is defined for use
with external resource links, where it sets the priority for fetching and
processing the linked resource, and browsers wire it up for `preload`,
`modulepreload`, scripts, images and iframes rather than for `prefetch`. Printing
it here implied a control that was not being exercised.

The `as` attribute stays. It gives the request the same destination the admin
screen will later ask for, which is what allows the prefetched response to be
reused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'login_head' fires for every login-family screen, not just the login form, and a
successful login does not necessarily land on an admin screen. Prefetching in
those cases spends the visitor's bandwidth on files they will never request.

Skip the prefetching entirely on the password reset, registration, logout
confirmation and check-your-email flows, on an interim login, which
re-authenticates inside a modal on a page that already has these assets, and when
`redirect_to` points outside the admin. An off-host `redirect_to` still prefetches,
because `wp_safe_redirect()` falls back to the admin in that case and
`wp_validate_redirect()` is used here to mirror that.

The set of handles itself does not need to vary with the destination. Every handle
listed loads on all admin screens rather than only on the Dashboard, since
`wp-admin` is an alias handle enqueued everywhere that pulls in `dashboard`,
`edit`, `themes`, `nav-menus` and the rest. Verified across the Dashboard, Posts,
Add New Post, Media, Plugins, Settings, Profile and Themes: all 6 scripts and 24
of the 25 styles appear on every one.

Drop the exception. `site-health` is concatenated on the Dashboard and nowhere
else, so it is the one handle that was tied to a particular screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@westonruter westonruter changed the title Script Loader: Preload the admin's unconcatenated assets from the login screen Script Loader: Pfetch the admin's unconcatenated assets from the login screen Aug 16, 2026
@westonruter westonruter changed the title Script Loader: Pfetch the admin's unconcatenated assets from the login screen Script Loader: Prefetch the admin's unconcatenated assets from the login screen Aug 16, 2026
Comment thread src/wp-includes/script-loader.php Outdated
westonruter and others added 2 commits August 16, 2026 16:14
A plugin adjusting the prefetched set almost always wants to know where the login
is about to land, and without it being handed over the only way to find out is to
read `redirect_to` back out of `$_REQUEST` and repeat the validation this function
has already done.

Pass the resolved destination as a second argument to `login_prefetch_admin_assets`.
It is the value wp_safe_redirect() will receive: `redirect_to` when the request
supplied one, the admin otherwise, already through wp_validate_redirect() so an
off-host value has fallen back to the admin. Resolve it unconditionally rather than
only when the request carries the argument, so the filter gets a usable value in
the common case where it does not.

The docblock notes that it may be relative, since a request-supplied path is passed
through unchanged and only the fallback is a full URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The login screen is not the only place the next screen can be guessed at. From
the Dashboard and the post list tables the editor is the usual next stop, and it
is by far the heaviest screen in the admin: landing there from the Dashboard
pulls in 47 files the Dashboard did not already have.

Prefetch the editor's stylesheets from those screens, and from the login screen
as well when `redirect_to` points at `post-new.php` or at `post.php` with
`action=edit`. Because handles the current screen has already printed are skipped,
each context only fetches what it is actually adding: 18 stylesheets from the
Dashboard or a post list, and those plus the admin-wide set from the login screen.

Stylesheets only. The editor's scripts come to roughly 1.26 MB compressed against
98 KB for its stylesheets, which is far too much to spend speculatively on a
screen the user may never open. The stylesheets are render-blocking and land in
the same size class as the login screen's existing prefetch.

Name the roots rather than the whole set. `_wp_expand_dependency_handles()` pulls
in whatever those roots depend on, so the list follows the dependencies declared
in `wp_default_styles()` instead of restating them: eight roots cover all eighteen
handles, and `wp-edit-post` alone accounts for most of the editor chrome.

Skip the whole thing for a user who cannot create the post type, and for a post
type still using the classic editor, which would load none of these.

Rename the filter from `login_prefetch_admin_assets` to `prefetch_admin_assets`,
since it is no longer login-specific, and describe its second argument as the
screen being prefetched for rather than as a redirect target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@westonruter westonruter changed the title Script Loader: Prefetch the admin's unconcatenated assets from the login screen Script Loader: Prefetch assets for the next admin screen Aug 18, 2026
westonruter and others added 10 commits August 18, 2026 13:56
Expanding a set of handles to include their dependencies reads nothing beyond
the registry's `registered` array and each item's `deps`, both of which are
declared on `WP_Dependencies` itself. Naming the two subclasses in the
signature therefore claimed more than the function needs, and turned away any
other registry that would work just as well.

Since the parameter now names a single class rather than a union, it also gains
a native type hint.

`_wp_resolve_dependency_urls()` keeps its `WP_Scripts|WP_Styles` union: it
reaches for `_css_href()`, `text_direction`, `base_url`, `content_url`, and
`default_version`, none of which the base class declares, and the union is what
gives its `instanceof WP_Styles` branch something to narrow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The roots handed to the handle expander are non-empty by contract, but the
handles reached through them are only ever known to be strings: the `deps`
property is documented as `string[]`, so an empty one would be queued, used as
an array key, and handed back to the caller as an empty handle to resolve.

Excluding it where a non-root handle enters the queue is the only place the
check is needed, and it lets the signature say what the function actually
returns: a list of non-empty handles, expanded from a non-empty list of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prefetched resources were keyed by URL, which collapsed handles resolving
to the same file but left the filter looking at a map whose keys repeated the
`href` beside them. Worse, it put the collapsing before the filter rather than
after, so a callback appending a URL core had already listed would have printed
a second link for it.

`wp_preload_resources()` had already settled all of this: the filter sees a
plain list of attribute arrays, duplicates are folded afterwards into a set
keyed by `href` with the first entry winning, and printing walks that set. Doing
the same here means a callback can append without first checking what is
already there, and anyone who has read one filter has read the other.

Printing stays a fixed `href`/`as` pair rather than the generic walk over an
attribute allowlist, since those two are the whole contract and `fetchpriority`
was deliberately dropped from these links earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Those are the two destinations core itself prefetches, but nothing in the code
constrains the attribute, and a callback with a reason to prefetch an image, a
font or a document should not read the documentation as ruling it out.

Describing the values the way `wp_preload_resources()` already does keeps the
two filters saying the same thing about the same attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deriving the registry from the destination meant a ternary re-answering, once
per group, a question the loop had just asked. Pairing the two in the array
being walked lets the destination stay what it is for, which is the value of
the `as` attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two passages were written while the login screen was the only place this ran,
and kept naming it after the Dashboard and the post list tables started
printing these links too: the note on skipping handles already printed, and the
explanation of why these are prefetches rather than preloads. Both describe how
the function behaves wherever it runs, so both now say so.

The remaining mentions are left alone, being the ones that are about the login
screen: its own bullet in the list of contexts, the admin-wide handles not
varying with where a login lands, the flows that print nothing, and everything
inside the branch that only runs on `login_head`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A helper named after the caller's concern rather than its own work is a helper
that exists only to shorten a call site, and this one had exactly one. Folding
it in costs eight lines in a function that reads no worse for them, and spares
the global namespace a permanent addition.

The two remaining helpers stay: resolving a handle to the URLs it loads from,
and expanding handles to include their dependencies, are both described without
reference to prefetching and would serve any caller that wanted them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing the parameter as WP_Dependencies passes static analysis, which is the
trap: with only WP_Scripts and WP_Styles in view, ruling out the one leaves the
other, so every member the URL is built from resolves. Gutenberg's WP_Fonts is
a third subclass and declares none of them, and a caller reaching this with one
would land on an undefined property several lines into the function rather than
a type error at its door.

Since the analyser cannot make that argument, the docblock does. The handle is
also documented as non-empty, matching the URLs already promised of the return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +2698 to +2699
$script_handles = array();
$style_handles = array();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lists of hard-coded handles below will need to be maintained. We should possibly add comments to where the assets are enqueued to note they should also note the list(s) here should be updated if anything changes.

Comment on lines +2503 to +2505
* Mirrors how {@see WP_Scripts::do_item()} and {@see WP_Styles::do_item()} build the
* URL they print, including the version query argument and the {@see 'script_loader_src'}
* and {@see 'style_loader_src'} filters, without printing anything or disturbing the queue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it mirrors, then this seems like duplication. Better to see if we can reuse what WP_Scripts and WP_Styles are already doing, rather than introduce a new _wp_resolve_dependency_urls() function.

* @phpstan-param non-empty-list<non-empty-string> $handles
* @phpstan-return list<non-empty-string>
*/
function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $handles ): array {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only used in one place, wp_prefetch_admin_assets(), so probably better to inline it so as to avoid adding a global function needlessly (even though it could make it easier to unit test).

@manzoorwanijk manzoorwanijk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me as a starting point. Should we commit it?

@westonruter

Copy link
Copy Markdown
Member Author

This remains high on my radar to get back to. I want to address the comments I raised and add some tests, but then, yes, we should commit!

westonruter and others added 3 commits September 25, 2026 21:45
The docblock argued for prefetch over preload partly on the grounds that
preload would leave reuse across the navigation dependent on the static files'
HTTP cache headers. Prefetch is no different. With the login screen loaded in a
fresh browser context under Fast 4G, a login a few seconds later found all 24
prefetched files in the cache without a request, while a login four minutes
later revalidated 18 of them with a 304. Those 18 were exactly the files whose
heuristic freshness, a tenth of the time since `Last-Modified`, had run out;
the six recent enough to stay fresh were still served from the cache. No
five-minute exemption for unused prefetches applied.

The case for prefetch still stands on priority and on preload's unused-resource
warnings, so the paragraph now rests on those, and a new one says what reuse
actually depends on: whatever freshness the server's headers, or their absence,
give the file. A stale response still saves the download, just not the round
trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin-wide list was taken from what load-scripts.php and load-styles.php
bundle, but being concatenated was only ever a stand-in for what matters,
which is holding back the first paint. With concatenation off, `hoverIntent`,
`wp-dom-ready` and `wp-hooks` are printed in the footer, and Chrome marks only
the stylesheets and the three head scripts as render-blocking on the Dashboard.
Measured login-to-Dashboard on Fast 4G, ten runs each, dropping the three made
no difference to LCP (894 ms against 900 ms median) or anything else.

The footer's scripts were benchmarked as a set too, since they do block
DOMContentLoaded. When their prefetch finishes before the login is submitted
the gain is large: DOMContentLoaded 3.6 s to 1.05 s on Fast 4G, LCP unchanged.
But the set is about 1.2 MB compressed, mostly the command palette's
dependencies, and on Fast 3G it had not finished 3 s or even 8 s after the
login screen loaded. What was still in flight kept downloading across the
navigation and competed with the Dashboard's render-blocking stylesheets,
costing 574 ms and 1,038 ms of LCP respectively. Plain prefetch links cannot
tell those cases apart, so the footer stays out, and the comment says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Benchmarks: prefetch reuse, what to prefetch, and slow connections

These were run on 2026-09-25/26 against this branch as of 87e9460 (before 9818520 narrowed the login set, which was chosen on the strength of the results below).

Setup

Unless stated otherwise:

  • Chrome driven through the DevTools protocol, with DevTools' own Fast 4G and Fast 3G network presets.
  • A fresh isolated browser context per run, so every run starts with an empty cache and no cookies.
  • A real login: the login screen is loaded, credentials are filled in, and the form is submitted after a fixed delay measured from the login screen's load event, simulating the time spent typing. Metrics are read on the Dashboard the login redirects to.
  • CONCATENATE_SCRIPTS false and SCRIPT_DEBUG false, so minified files and no concatenation, the case this PR targets.
  • Local wp-env over HTTP/1.1, so six connections per origin. The local server sends no Cache-Control or Expires for static files, only Last-Modified and ETag.
  • Timings come from Navigation Timing, largest-contentful-paint and paint entries, and Resource Timing, all relative to the start of the login form's POST, so they include the redirect. Cache hits are resources with a transferSize of 0 on the Dashboard.
  • Medians of 10 runs, with the full range in brackets.

1. Are prefetched responses reused across the navigation?

This had been verified for the first revision's preload, but not for prefetch.

Login submitted Served from cache, no request Revalidated (304)
~4 s after the prefetch 24 / 24 0
241 s after the prefetch 6 18

Yes, but only for as long as ordinary HTTP freshness allows. With no explicit caching headers, Chrome gives each file a heuristic lifetime of a tenth of the time since its Last-Modified. The 18 files that revalidated at 241 s were exactly those whose lifetime had run out: they had been rebuilt minutes earlier, giving lifetimes of 61–105 s. The six that stayed cache hits were old enough to be fresh for hours or days. No five-minute exemption for unused prefetches applied. A 304 still saves the body (about 300 bytes crossed the wire) but costs a round trip, which was 170–860 ms each under Fast 4G.

A freshly built checkout is close to the worst case for this. On a production site core's files are typically unchanged for weeks, and many hosts send a long max-age besides.

2. What actually blocks LCP on the Dashboard?

One cold-cache Dashboard load under Fast 4G:

  • LCP was 1556 ms, on the "Welcome to WordPress!" heading, which was also the first paint.
  • Chrome's renderBlockingStatus marks 31 resources as blocking: 28 stylesheets and 3 scripts, jquery, jquery-migrate and utils. Those three are exactly the scripts printed in the head.
  • Footer scripts didn't hold up LCP. 99 of the 104 files loaded in the body finished after LCP, the last one at 4443 ms.
  • The prefetch set, as it stood, missed two things. It covered 21 of the 31 blocking resources, and the login screen loads 6 more itself. It also included three files that don't block rendering: hoverIntent, wp-dom-ready and wp-hooks. They were in the head load-scripts.php bundle, but with concatenation off they print in the footer. The 4 blocking resources left uncovered are expected:
    • site-health and thickbox stylesheets: Dashboard-only.
    • A plugin stylesheet.
    • colors: the per-user color scheme.

3. What loads on every admin screen but isn't prefetched?

12 admin screens were fetched, and the core scripts and stylesheets present on every one were kept:

Group Files Gzipped
Prefetched from the login screen 24 121 KB
Already loaded by the login screen itself 8 49 KB
Loaded on every admin screen, not prefetched 50 1,110 KB

Of the 50, colors.css is the per-user scheme. The other 49 are blocking footer scripts:

  • About 14 KB: common, hoverintent-js, admin-bar, heartbeat, svg-painter and wp-auth-check.
  • About 1,090 KB: the command palette's dependencies. wp_enqueue_command_palette_assets() runs on every admin screen, and its wp-core-commands depends on wp-core-data, which depends on wp-block-editor (467 KB) and wp-components (260 KB), among others.

4. Which set should the login screen prefetch?

Three sets were compared by adding or removing entries through the prefetch_admin_assets filter:

Arm Prefetched from the login screen Links
A The concatenated set, as this PR had it: 18 stylesheets and 6 scripts 24
B A without hoverIntent, wp-dom-ready and wp-hooks, leaving only what blocks rendering. Now the PR's behavior 21
C B plus all 73 blocking footer scripts the Dashboard loads (about 1.2 MB gzipped) 94

Fast 4G, login submitted 3 s after the login screen loaded (10 runs per arm):

Arm Done at submit Cache hits on Dashboard LCP DCL Load
A 24 / 24 24 900 [872–940] 3691 [3644–3762] 3923
B 21 / 21 21 894 [860–1036] 3607 [3579–3748] 3875
C 94 / 94 94 896 [852–1060] 1049 [1006–1225] 1393
  • A vs B: no difference beyond noise. Dropping the three footer scripts costs nothing.
  • C: LCP unchanged, while DCL falls by 2.56 s (−71%) and load by 2.48 s. The DCL ranges don't overlap.

Fast 3G, login submitted 3 s after the login screen loaded (10 runs each of B and C):

Arm Done at submit Cache hits on Dashboard Dashboard HTML responseStart LCP DCL Load
B 21 / 21 21 942 1860 [1852–1868] 13513 14237
C 25 / 94 42 1042 2434 [2264–2688] 11886 [7832–11946] 12608

Fast 3G, login submitted 8 s after the login screen loaded (10 runs each):

Arm Done at submit Cache hits on Dashboard Dashboard HTML responseStart LCP DCL Load
B 21 / 21 21 945 1860 [1852–1868] 13522 14247
C 72 / 94 89 1163 2898 [2844–2924] 6574 [6538–6605] 7299

On Fast 3G, C improves DCL by 1.6 s with the 3 s delay and 6.9 s with the 8 s delay. But it makes LCP worse by 574 ms and 1,038 ms respectively, and the LCP ranges don't overlap B's in either case. More typing time made LCP worse, not better:

  • With 8 s:
    • Unfinished prefetches kept downloading after submit. 22 were still downloading at submit, but 89 of the 94 were cache hits on the Dashboard. So 17 of those finished after the page changed and were reused, and only 5 small files were downloaded again.
    • The Dashboard's HTML arrived 218 ms later, and the time from that response to first paint grew from about 915 ms to about 1735 ms.
    • This is consistent with the remaining large command-palette files competing with the Dashboard's render-blocking stylesheets for bandwidth.
  • With 3 s: far fewer prefetches carried over. Only 42 were cache hits and about 52 were downloaded again by the Dashboard itself, at footer-script priority after the stylesheets. That fits the smaller LCP cost.

The competition for bandwidth is inferred from these counts and timings; it wasn't traced at the network level. By the rate observed, the full C set needs roughly 11–12 s to finish on Fast 3G.

One C run with the 3 s delay was an outlier: 63 of 94 prefetches had finished at submit, and DCL was 7832 ms. It was the first run of that series and immediately followed a browser page that failed to open. It is kept in the data above.

The login screen itself was unaffected by the prefetch set's size. Its load event was 902–913 ms on Fast 4G in all three arms, including the 94-link one, and 3154–3188 ms on Fast 3G in both B and C. The first revision's preload had pushed it from 908 ms to 1597 ms. That regression was a preload artifact and is gone.

Conclusions

  • Prefetched responses are reused across the login redirect. How long they stay reusable depends on the static files' HTTP freshness, not on anything specific to prefetch.
  • LCP on the admin is blocked only by the stylesheets and the three head scripts, so that is the right set to prefetch from the login screen. It is safe on both connection speeds tested. It is now the PR's behavior (9818520).
  • Prefetching the footer scripts too roughly halves DCL and load when it finishes before the login is submitted. It makes LCP 0.6–1.0 s worse on Fast 3G when it doesn't finish, which even an 8 s delay wasn't enough for. Plain <link rel="prefetch"> tags can't abandon a prefetch that's still downloading when the form is submitted. A fetch() with an AbortController cancelled on submit might keep the gain without the cost. That hasn't been tried, and it's unverified whether a response cached by fetch() is reused by a later <script>.

Caveats

  • One machine, one site, one page (the Dashboard), over HTTP/1.1 on localhost with DevTools throttling. Over HTTP/2 the request-count effects would shrink.
  • Only Chrome was tested.
  • The recovery against a no-prefetch control hasn't been re-run for the current implementation. The PR description still carries the first revision's figures for that.
  • The editor case, Dashboard → Add New Post, hasn't been benchmarked.

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Does prefetch reuse depend on far-future caching headers?

No. A prefetched file is served from the cache without a request whenever it is still fresh. Freshness doesn't need an explicit max-age: for most sites it comes from Last-Modified, as the conditions below explain.

How long a response stays fresh without caching headers

If a response has no Cache-Control: max-age and no Expires, the browser estimates how long it stays fresh from Last-Modified. RFC 9111 §4.2.2 suggests using 10% of the time since the file was last modified, and Chrome does exactly that. The benchmarks above show it working:

Login submitted Server's caching headers Cache hits, no request Revalidated (304)
~4 s after the prefetch Last-Modified and ETag only 24 / 24 0
241 s after the prefetch Last-Modified and ETag only 6 / 24 18
241 s after the prefetch Cache-Control: max-age=31536000 21 / 21 0

At 241 s, the six files that were still cache hits were the ones old enough for their estimated freshness to cover the wait. The 18 that revalidated had been rebuilt minutes before the test. With an explicit one-year max-age, every file was a cache hit.

The last row used an opt-in far-future expires setting for the local Docker environment's nginx. It will be proposed in its own ticket, since it's useful for performance testing generally: without it, local caching depends on how recently the assets were built.

When prefetch reuse works

A prefetched file is reused without revalidation when all of these hold:

  1. No caching directive overrides the estimate. Estimated freshness applies only when the response has neither max-age nor Expires. A server sending Cache-Control: no-cache, max-age=0, or an Expires in the past forces a revalidation every time. An explicit long max-age also works.
  2. Last-Modified is sent. nginx and Apache send it for static files by default. A server or CDN that sends only an ETag gives the browser nothing to estimate from, so every reuse becomes a 304.
  3. The file is old enough compared with the wait before login. It needs to be about ten times older than the time between the prefetch and the login. A login 30 s after the prefetch needs files at least 5 minutes old; a login 5 minutes later needs them at least 50 minutes old. In production, core's files normally don't change between updates, so this fails mainly just after a core update or deploy.

When any of these don't hold, the prefetch still saves the download: the Dashboard sends a conditional request and gets a 304 instead of the file. It never makes things worse than not prefetching.

Two limits to this:

  • Only Chrome was tested. Chrome estimated freshness for URLs with a ?ver= query string, which applies to almost every core asset. RFC 2616 used to tell caches not to do that for URLs with query strings; RFC 9111 dropped that rule. Firefox and Safari haven't been checked.
  • The benchmarks were local only, on the server described in the benchmark comment above.

What HTTP Archive and the Web Almanac say

No published data breaks static-asset caching down by CMS, so there's no WordPress-specific figure. Across all sites:

  • Web Almanac 2021, Caching, the most recent dedicated caching chapter:
    • About 74–75% of responses include Cache-Control, about 55% include Expires, and about 25% include neither.
    • The median max-age for scripts and CSS is 30 days, and 30 days is the most common value overall.
    • So long max-age values are widespread for static files, but far from universal.
  • Web Almanac 2022, Sustainability repeats that more than a quarter of sites send no caching headers at all. It breaks page weight down by CMS, but not caching.
  • Web Almanac 2024, Sustainability has a "Caching" heading with no data under it.

For this PR, what matters is conditions 1 and 2 above, not how common far-future caching is, so this data isn't needed to judge it.

Why it matters for Core-57548

The broader ticket does depend on this. load-scripts.php and load-styles.php send their own caching headers:

$expires_offset = 31536000; // 1 year.
header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + $expires_offset ) . ' GMT' );
header( "Cache-Control: public, max-age=$expires_offset" );

On a server with no caching rules for static files, retiring concatenation replaces bundles cached for a year with individual files that only get estimated freshness. Repeat visits would stay cached for most of a release cycle, but revalidate more after each update. How many WordPress sites are in that position is something HTTP Archive can answer. HTTP Archive only crawls public pages, never wp-admin, but the same server rules normally cover /wp-includes/, which the front end loads from. The query below is a starting point. It hasn't been run yet, and should get a dry run first to check how much data it scans:

Draft HTTP Archive query: caching headers on WordPress core JS and CSS
-- JS and CSS that WordPress sites serve from their own core directories,
-- bucketed by explicit freshness lifetime. One crawl, mobile, root pages.
DECLARE crawl DATE DEFAULT '2026-08-01';

WITH wp_pages AS (
  SELECT DISTINCT page
  FROM `httparchive.crawl.pages`
  WHERE date = crawl
    AND client = 'mobile'
    AND is_root_page
    AND EXISTS (SELECT 1 FROM UNNEST(technologies) AS t WHERE t.technology = 'WordPress')
),

core_assets AS (
  SELECT
    r.page,
    r.type,
    LOWER((SELECT STRING_AGG(h.value, ', ') FROM UNNEST(r.response_headers) AS h WHERE LOWER(h.name) = 'cache-control')) AS cache_control,
    EXISTS (SELECT 1 FROM UNNEST(r.response_headers) AS h WHERE LOWER(h.name) = 'expires') AS has_expires,
    EXISTS (SELECT 1 FROM UNNEST(r.response_headers) AS h WHERE LOWER(h.name) = 'last-modified') AS has_last_modified
  FROM `httparchive.crawl.requests` AS r
  JOIN wp_pages USING (page)
  WHERE r.date = crawl
    AND r.client = 'mobile'
    AND r.is_root_page
    AND r.type IN ('script', 'css')
    AND NET.HOST(r.url) = NET.HOST(r.page)          -- served by the site itself, not a CDN rewrite
    AND REGEXP_CONTAINS(r.url, r'/wp-(?:includes|admin)/')
),

classified AS (
  SELECT
    *,
    SAFE_CAST(REGEXP_EXTRACT(cache_control, r'(?:^|[,\s])max-age\s*=\s*"?(\d+)') AS INT64) AS max_age
  FROM core_assets
)

SELECT
  type,
  CASE
    WHEN REGEXP_CONTAINS(IFNULL(cache_control, ''), r'no-store|no-cache') OR max_age = 0 THEN '1: always revalidate'
    WHEN max_age IS NULL AND NOT has_expires AND has_last_modified THEN '2: heuristic only (Last-Modified)'
    WHEN max_age IS NULL AND NOT has_expires THEN '3: no freshness, no Last-Modified'
    WHEN max_age IS NULL THEN '4: Expires only'
    WHEN max_age < 86400 THEN '5: under 1 day'
    WHEN max_age < 2592000 THEN '6: 1 to 29 days'
    WHEN max_age < 31536000 THEN '7: 30 days to under 1 year'
    ELSE '8: 1 year or more'
  END AS freshness,
  COUNT(DISTINCT page) AS sites,
  COUNT(*) AS responses,
  ROUND(100 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY type), 1) AS pct_of_responses
FROM classified
GROUP BY type, freshness
ORDER BY type, freshness;

The schema follows the requests and pages table references. The "Expires only" bucket isn't converted into a lifetime.

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Benchmarks: today's default vs this PR, login → Dashboard

These replace the first revision's figures in the PR description. They compare WordPress's current default (concatenation on) with this PR's configuration (concatenation off, prefetching on), plus a control arm with concatenation off and no prefetching. The runs were on 2026-09-26 against this branch at 9818520, with the local nginx caching option from a separate commit enabled.

Setup

  • Browser: Chrome, driven through the DevTools protocol, using DevTools' own Fast 4G and Slow 4G network presets.
  • A fresh isolated browser context per run, so every run starts with an empty cache and no cookies.
  • A real login each run: the login screen loads, credentials are filled in, and the form is submitted 3 s after the login screen's load event. Metrics are read on the Dashboard the login redirects to.
  • Settings: SCRIPT_DEBUG off. Concatenation is switched per arm with the CONCATENATE_SCRIPTS constant, on both the login screen and the Dashboard.
  • Server: local wp-env over HTTP/1.1, with gzip on and static files served with Cache-Control: max-age=31536000, as a typical production host would.
  • Timings: from Navigation Timing and the paint and largest-contentful-paint entries. They're measured from the start of the login form's POST, so they include the redirect. Request counts and bytes cover only what the Dashboard fetched over the network.
  • 10 runs per arm, with the arms interleaved (concat, none, prefetch, concat, …). Medians are reported, with the full range in brackets.
Arm Concatenation Login screen prefetches
concat on (today's default) 0 (the concatenation check suppresses them)
none off 0 (removed through the prefetch_admin_assets filter)
prefetch off 21 (this PR)

Fast 4G

concat none prefetch
LCP 1134 [1104–1164] 1412 [1360–1536] 840 [824–880]
FCP 1124 [1088–1164] 1412 [1360–1536] 840 [824–880]
DOMContentLoaded 3596 [3553–3640] 4105 [4057–4246] 3650 [3626–3668]
load 3597 [3555–3641] 4107 [4058–4248] 3651 [3627–3669]
Dashboard requests over the network 83 118 99
Dashboard bytes transferred 1,511 KB 1,456 KB 1,367 KB
Login screen FCP 532 588 584
Login screen load 698 920 912

Slow 4G

concat none prefetch
LCP 3494 [3464–3508] 3806 [3764–3836] 1872 [1852–1880]
FCP 3474 [3460–3508] 3806 [3764–3836] 1872 [1852–1880]
DOMContentLoaded 13286 [13265–13309] 15207 [15174–15241] 13302 [13286–13325]
load 13287 [13266–13310] 15209 [15176–15243] 13303 [13287–13326]
Dashboard requests over the network 81 116 97
Dashboard bytes transferred 1,490 KB 1,437 KB 1,346 KB
Login screen FCP 1892 1812 1812
Login screen load 2418 3195 3181

In current Chrome, "Slow 4G" is the preset formerly named "Fast 3G". The prefetch arm's LCP of 1872 ms matches the earlier Fast 3G runs of the same set (1860 ms), which is consistent with that.

Findings

  • LCP is much faster than today's default: 294 ms (−26%) faster on Fast 4G and 1,622 ms (−46%) faster on Slow 4G. The three arms' LCP ranges don't overlap on either network.
  • DOMContentLoaded and load are essentially unchanged: +54 ms (+1.5%) on Fast 4G and +16 ms (+0.1%) on Slow 4G. The prefetch doesn't cover footer scripts, which is where the admin's bytes mostly are. Those download after the redirect in every arm.
  • Retiring concatenation with no mitigation costs: 278–312 ms of LCP, 509 ms of DOMContentLoaded on Fast 4G, and 1.9 s of it on Slow 4G. Prefetching recovers all of that for LCP and nearly all of it for DOMContentLoaded.
  • Why the prefetch arm beats concatenation on LCP: the login screen concatenates too when the constant is on, into a bundle of its own: load-styles.php?…load[chunk_0]=dashicons,buttons,forms,l10n,wp-base-styles,wp-tooltip,login. The Dashboard's bundles combine different handles, so it can reuse nothing and downloads everything from scratch. With concatenation off, the Dashboard reused the six files the login screen had already loaded plus all 21 prefetched ones. Every prefetched file was a cache hit in every run of the prefetch arm.
  • Not all of the lead comes from dropping concatenation. Some of it comes from prefetching as such. A concatenated setup could in principle prefetch its own bundle URLs from the login screen, though they differ per admin screen. That hasn't been measured.
  • The login screen's own cost comes from concatenation being off, not from the prefetch. Its load rises by 214 ms on Fast 4G and 777 ms on Slow 4G, the same in the no-prefetch and prefetch arms. Its FCP barely moves.

Correction

Earlier revisions of the description said the login screen never concatenates, even with the constant on. On this setup it does, as the bundle URL above shows. Whether it does depends on whether script_concat_settings() runs before login_init; the PR description now says so.

Caveats

  • One machine, one site, one page (the Dashboard), over HTTP/1.1 on localhost with DevTools throttling. Over HTTP/2 the effects of request count shrink, which should narrow the gaps between the arms.
  • Only Chrome was tested, and only with fresh browser contexts. Repeat logins with a warm cache weren't measured; the arms should converge there.
  • The login is submitted 3 s after the login screen loads. That's enough for the 21-file prefetch to finish on both networks.
  • The editor case (Dashboard → Add New Post) hasn't been benchmarked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants