Skip to content

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

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

westonruter wants to merge 37 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. 18 tags.

The two compose: a login whose redirect_to points at post-new.php, or at post.php?action=edit, prefetches both sets — 39 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.

Further along: the Posts list and the block editor

A longer journey was also benchmarked, with the same three arms: login → Dashboard → Posts list → editing an existing post in the block editor. It reproduces the Dashboard results above, and the gains carry forward. Full tables and method are in the journey benchmark comment. In brief, with prefetch compared with today's default (concatenation):

  • Posts list: FCP is 58% faster on Fast 4G and 70% faster on Slow 4G (2.1 s → 0.6 s), and 84% less is transferred (175 KB → 28 KB). This comes from dropping concatenation rather than from prefetching: each screen's bundles are different URLs, so the concatenated list screen re-downloads assets it already has.
  • Block editor: because the Dashboard and the list table prefetch the editor's stylesheets, FCP is 44% faster on Fast 4G and 70% faster on Slow 4G (4.7 s → 1.4 s). The editor is ready (post title visible in the canvas) 11% sooner on Fast 4G and 15% sooner on Slow 4G (15.1 s → 12.8 s), with 19% fewer bytes transferred.

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

  • wp_prefetch_admin_assets() in src/wp-includes/script-loader.php decides whether a next screen can be predicted, builds the list and prints it. It is 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_Scripts::get_src() is new: the URL-building that was inline in WP_Scripts::do_item(), moved into a method that do_item() now calls. It is modeled on WP_Script_Modules::get_src().
  • WP_Styles::get_rtl_href() is new: likewise the right-to-left href logic from WP_Styles::do_item(). It returns null when no right-to-left stylesheet applies, so do_item() prints one in exactly the same cases as before. The left-to-right href comes from the existing WP_Styles::_css_href().

The prefetch builds its URLs with the same methods do_item() uses to print its tags, so they cannot drift apart. An earlier revision copied that code into a private helper, and the copy had already fallen behind: it dropped the handle's query arguments and the URL fragment. Both sets of URLs are identical for every registered script and style (237 and 421), in both text directions.

Both sets are expressed as roots, so they follow the dependencies declared in wp_default_scripts() and wp_default_styles() rather than restating them. They are expanded to everything they depend on inline, since there is only one caller.

  • The login screen has 2 script roots (jquery, utils) and 5 style roots (wp-admin, buttons, admin-bar, wp-auth-check, wp-commands). These expand to the same 24 stylesheets and 3 scripts the earlier flat list named.
  • The editor has 6 style roots, led by wp-edit-post, which alone accounts for most of the editor chrome.

Each root mirrors an enqueue elsewhere. That enqueue now carries a comment saying wp_prefetch_admin_assets() needs updating if it changes, and the comment above the roots lists those enqueues, so the two can be kept in sync from either side. Roots already reachable from another root were dropped, leaving fewer enqueues to keep in sync: wp-pointer (reached through wp-admin) and the editor's wp-block-editor-content and wp-reset-editor-styles (reached through wp-edit-post).

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. Add New Post specifically. Opening an existing post from the Posts list has been benchmarked end to end (see "Further along" above), but going from the Dashboard to Add New Post has not.
  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 both the login and editor lists from roots rather than hardcoding them, with comments linking each root and its enqueue (5, partly — there is no drift test); URLs now come from the same methods do_item() uses, which fixes the args/#fragment handling (7); documented in the filter's docblock that on the login screen the src filters run before authentication and outside the admin, so a callback depending on either can produce a URL the admin will not request (8); 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 roots against what the admin actually loads (5); locale mismatch between the login screen and the admin (6); idempotency (12).

Tests

  • Tests_Dependencies_WpPrefetchAdminAssets covers wp_prefetch_admin_assets():
    • what the login screen prefetches, and when, depending on the login action and redirect_to
    • what the Dashboard and post list tables prefetch for the editor, and for which users and post types
    • skipping handles already printed, and right-to-left stylesheets
    • how the filter's result is deduplicated and sanitized
  • Prefetching depends on the CONCATENATE_SCRIPTS constant, so each of those tests defines it in a separate process, which makes the class slow (about 37 s). A @todo notes that this goes away once concatenation is retired in Core-57548.
  • Tests_Dependencies_WpScripts_GetSrc and Tests_Dependencies_WpStyles_GetRtlHref cover the two new methods, including that each returns the URL do_item() prints.

Writing those tests turned up an existing bug in WP_Styles::do_item(), which get_rtl_href() keeps rather than fixes. A style registered with a null version prints its left-to-right stylesheet without ?ver=, but its right-to-left stylesheet gets the default version. Handle arguments are also mixed into the right-to-left stylesheet's version string. This may be worth a separate ticket.

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 39
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 18
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 thread src/wp-includes/script-loader.php Outdated
Comment thread src/wp-includes/script-loader.php Outdated
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.

Add an nginx `expires` directive for CSS, JavaScript, font and image files to
the config template used by the local Docker environment, controlled by a new
`LOCAL_NGINX_STATIC_EXPIRES` option. It defaults to `off`, which leaves the
headers exactly as they are; setting it to a duration such as `1y` sends
`Cache-Control: max-age` and `Expires` the way a typical production server
does.

Without it, the local environment sends only `Last-Modified` and `ETag` for
static files, so browsers fall back to a heuristic lifetime of a tenth of each
file's age. That makes caching behavior depend on how recently the files were
built, which no benchmark records, and turns into a revalidation round trip
any reuse that production would serve straight from the cache. It also treats
the two sides of a concatenation comparison differently: `load-scripts.php`
and `load-styles.php` already send a one-year `max-age` of their own, while
the individual files they replace get none.

A missing file still falls through to WordPress, as it does under
`location /`, so a virtual asset such as the dynamic `/favicon.ico` keeps
working.

The option is opt-in because most core assets are versioned by the WordPress
version rather than by their content, so with a far-future lifetime an edited
file is served stale until a hard reload.

Since this changes `docker-compose.yml` in addition to the nginx template,
existing checkouts need to recreate the web server container to pick up the
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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.

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Benchmark: login → Dashboard → Posts list → block editor

The earlier benchmarks stopped at the Dashboard. This one follows a longer user journey to see whether the gains carry forward to later screens, and how the block editor is affected:

  1. Log in (3 seconds on the login screen before submitting).
  2. Dashboard.
  3. Posts list (edit.php).
  4. Edit "Hello world!" in the block editor (post.php?post=1&action=edit).

Three arms were compared:

  • concat: CONCATENATE_SCRIPTS on, i.e. the current default behavior.
  • none: concatenation off, nothing prefetched.
  • prefetch: concatenation off, with this PR's prefetching.

Method

  • Chrome via DevTools MCP, each journey in a fresh isolated browser context (cold cache).
  • Fast 4G and Slow 4G network emulation.
  • SCRIPT_DEBUG off; static assets served with LOCAL_NGINX_STATIC_EXPIRES=1y (see Core-66194, committed in r63946) so that individual files are cacheable like the concatenated bundles are.
  • 3 runs per arm per network, interleaved. Runs landed within about 1–2% of each other, so the medians are stable.
  • The block editor renders its content inside the editor-canvas iframe, so the parent document's LCP is not meaningful there. It is replaced with Editor ready: the time at which the post title "Hello world!" is visible in the canvas.

All figures are medians. In the last two columns, negative means prefetch is lower, which is better for every metric shown.

Fast 4G

Login screen

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 524 572 580 +11% +1%
Onload (ms) 692 912 915 +32% +0%

Dashboard

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 1,148 1,424 864 −25% −39%
LCP (ms) 1,148 1,424 864 −25% −39%
DCL (ms) 3,607 4,056 3,684 +2% −9%
Onload (ms) 3,608 4,057 3,686 +2% −9%
Requests 81 98 95 +17% −3%
Transferred (KB) 1,492 1,334 1,344 −10% +1%

Posts list

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 576 236 244 −58% +3%
LCP (ms) 576 236 244 −58% +3%
DCL (ms) 703 529 541 −23% +2%
Onload (ms) 713 539 550 −23% +2%
Requests 5 4 4 −20% +0%
Transferred (KB) 175 28 28 −84% +0%

Block editor

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 1,208 1,210 676 −44% −44%
Editor ready (ms) 3,598 3,684 3,198 −11% −13%
DCL (ms) 3,055 3,316 2,832 −7% −15%
Onload (ms) 3,066 3,328 2,844 −7% −15%
Requests 47 62 45 −4% −27%
Transferred (KB) 2,220 1,897 1,794 −19% −5%

Slow 4G

Login screen

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 1,896 1,816 1,800 −5% −1%
Onload (ms) 2,420 3,180 3,187 +32% +0%

Dashboard

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 3,484 3,804 1,860 −47% −51%
LCP (ms) 3,492 3,804 1,860 −47% −51%
DCL (ms) 13,286 14,532 13,290 +0% −9%
Onload (ms) 13,288 14,533 13,291 +0% −9%
Requests 81 98 96 +19% −2%
Transferred (KB) 1,492 1,334 1,346 −10% +1%

Posts list

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 2,108 632 624 −70% −1%
LCP (ms) 2,108 648 648 −69% +0%
DCL (ms) 2,218 1,442 1,428 −36% −1%
Onload (ms) 2,228 1,451 1,438 −35% −1%
Requests 5 4 4 −20% +0%
Transferred (KB) 175 28 28 −84% +0%

Block editor

Metric concat none prefetch prefetch vs concat prefetch vs none
FCP (ms) 4,730 3,366 1,434 −70% −57%
Editor ready (ms) 15,097 14,632 12,790 −15% −13%
DCL (ms) 13,586 13,804 11,970 −12% −13%
Onload (ms) 13,598 13,817 11,982 −12% −13%
Requests 47 62 45 −4% −27%
Transferred (KB) 2,220 1,897 1,794 −19% −5%

Observations

  • Login screen: onload is 32% higher than with concatenation because the 21 prefetch requests are counted in it. FCP is within 1% of the no-prefetch arm, so the prefetching is not visible to the user.
  • Dashboard: the earlier login → Dashboard results are reproduced. On Slow 4G, LCP is roughly halved compared with concatenation (1.86 s vs 3.49 s), and DCL and onload match concatenation while being about 1.2 s faster than no prefetching.
  • Posts list: concatenation is slowest here. The load-scripts.php and load-styles.php bundles are specific to each screen's handle list, so the list screen re-downloads 175 KB of assets that are already cached as individual files. On Slow 4G that costs about 1.5 s of FCP. Without concatenation, only the HTML (28 KB) is fetched, with or without prefetching.
  • Block editor: prefetching leads on every metric. Because the Dashboard and list table screens prefetch the render-blocking assets of the "Add New Post" screen, which is the block editor, those assets are already cached when an existing post is opened (45 requests instead of 62). On Slow 4G, FCP drops from 4.7 s with concatenation to 1.4 s, and the editor is ready 2.3 s sooner. The concatenated editor also transfers 19% more, because its bundles duplicate files already cached from earlier screens.

westonruter and others added 8 commits September 26, 2026 21:48
List the login screen's prefetched assets as a few roots, as the editor
set already is, instead of restating the 24 stylesheets and 3 scripts
they expand to. `wp-admin` stands for the admin's own stylesheets and
`jquery` for `jquery-core` and `jquery-migrate`, so the set now follows
the dependencies declared in `wp_default_styles()` and
`wp_default_scripts()` without needing to be kept in sync by hand.

Both sets of roots are expanded once, inline, so the single-use
`_wp_expand_dependency_handles()` helper is removed.

The printed prefetch links are unchanged on the login screen, on a
login redirecting to `post-new.php`, and on the Dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the construction of a script's URL out of `WP_Scripts::do_item()`
into a new `WP_Scripts::get_src()`, and the construction of a style's
right-to-left URL out of `WP_Styles::do_item()` into a new
`WP_Styles::get_rtl_href()`. `do_item()` calls them in place of the
inline code, and `wp_prefetch_admin_assets()` calls them together with
the existing `WP_Styles::_css_href()`.

This replaces `_wp_resolve_dependency_urls()`, which copied that code
and had already fallen behind it: the copy did not append the handle's
query arguments or keep a URL fragment after the version.

`get_src()` follows `WP_Script_Modules::get_src()`. `get_rtl_href()`
returns null when no right-to-left stylesheet applies, so `do_item()`
still prints one exactly when it did before. It narrows the `suffix`
and `rtl` data to strings, which the moved code assumed without
checking.

The URLs are unchanged for every registered script and style, in both
text directions, and so are the printed prefetch links on the login
screen and in the admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover how each method builds its URL: the base URL, content URL and
version handling, query strings, fragments and handle arguments for
scripts, and each form of `rtl` data and the `suffix` for styles. Also
cover the cases that return nothing, the `script_loader_src` and
`style_loader_src` filters, and that each URL matches the one
`do_item()` prints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login screen prefetches the dependencies of a handful of root
handles, each of which mirrors an enqueue elsewhere in the admin. Add a
comment at each of those enqueues saying that
`wp_prefetch_admin_assets()` needs updating if it changes, and list the
enqueue sites in the comment above the roots, so that the two are kept
in sync from either side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `wp-pointer` stylesheet is already a dependency of `widgets`, which
`wp-admin` depends on, so expanding the `wp-admin` root prefetches it on
its own. Listing it as a root also pointed at
`WP_Internal_Pointers::enqueue_scripts()` as the enqueue to keep in sync
with, which only enqueues it while the user has a pointer left to
dismiss. Remove the root and the note there.

The prefetched set is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`wp-edit-post` depends on `wp-edit-blocks`, which depends on both
`wp-block-editor-content` and `wp-reset-editor-styles`, so expanding
`wp-edit-post` already prefetches them. Remove them as roots so there
are fewer enqueues to keep in sync with.

The prefetched set is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As for the login screen's roots, add a comment at each enqueue that the
editor's prefetch roots mirror, saying that `wp_prefetch_admin_assets()`
needs updating if it changes, and list those enqueues in the comment
above the roots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover what the login screen prefetches and when, depending on the
login action and `redirect_to`; what the Dashboard and post list tables
prefetch for the editor, and for which users and post types; skipping
handles already printed; right-to-left stylesheets; and how the
`prefetch_admin_assets` filter's result is deduplicated and sanitized.

Prefetching depends on the `CONCATENATE_SCRIPTS` constant, so each test
defines it in a separate process. A todo notes that this goes away once
concatenation is retired.

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

Copy link
Copy Markdown
Member Author

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

@manzoorwanijk I did a bunch more performance benchmarking to verify that prefetching is a win (it is). I also refactored the logic to eliminate logic duplication among other improvements. I also added the missing tests.

Ready for review (and commit).

Then we can move forward with #13090 from @haqadn.

@westonruter
westonruter marked this pull request as ready for review September 27, 2026 05:44
@westonruter
westonruter requested a lite review from Copilot September 27, 2026 05:44
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, adnan007, manzoorwanijk.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Unresolved moderate findings remain in prefetch gating, redirect validation, capability checks, and URL rewrite compatibility.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

Adds speculative prefetching for likely next admin screens and centralizes script/style URL generation.

Changes:

  • Prefetches login-to-admin and admin-to-editor assets.
  • Adds reusable script and RTL stylesheet URL helpers.
  • Adds hooks, synchronization documentation, and PHPUnit coverage.
File Reviewed changes Findings
tests/​phpunit/​tests/​dependencies/​wpStyles/​getRtlHref.php Tests RTL stylesheet URL generation. None
tests/​phpunit/​tests/​dependencies/​wpScripts/​getSrc.php Tests script URL generation. None
tests/​phpunit/​tests/​dependencies/​wpPrefetchAdminAssets.php Tests prefetch behavior and filtering. None
src/​wp-includes/​script-loader.php Implements prefetching and URL assembly. 4 moderate findings and 2 nit findings remain: action overrides, external redirect hosts, missing edit_posts validation, filter context documentation, tag-filter URL rewrites, and missing drift coverage.
src/​wp-includes/​media.php Documents editor stylesheet roots. None
src/​wp-includes/​functions.php Documents authentication-check stylesheet usage. None
src/​wp-includes/​default-filters.php Registers prefetch hooks. None
src/​wp-includes/​class-wp-styles.php Adds RTL stylesheet URL helper. None
src/​wp-includes/​class-wp-scripts.php Adds script source URL helper. None
src/​wp-includes/​class-wp-editor.php Documents editor stylesheet usage. None
src/​wp-includes/​class-wp-admin-bar.php Documents admin-bar stylesheet usage. None
src/​wp-admin/​edit-form-blocks.php Documents editor enqueue synchronization. None
src/​wp-admin/​admin.php Documents common script synchronization. None
src/​wp-admin/​admin-header.php Documents admin asset synchronization. None

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/wp-includes/script-loader.php
westonruter and others added 3 commits September 26, 2026 23:44
Expanded comment to clarify resource handling and filtering.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Restore the paragraph on the `prefetch_admin_assets` filter's output
to its original indentation and wording, and explain the login
screen's execution context in a paragraph of its own: the URLs are
built before the user is authenticated and outside the admin, so a
`script_loader_src` or `style_loader_src` callback depending on either
can produce a URL the admin screen will not request.

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

@westonruter westonruter left a comment

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.

🤖 Comment by Claude Opus 5.5

Findings from a code review of wp_prefetch_admin_assets(), each checked against the code. All are low severity: each either wastes a prefetch or misses a useful one, and none affects rendering. The refactors of WP_Scripts::do_item() and WP_Styles::do_item() were confirmed to keep their previous behavior. These are left for a follow-up.

Comment thread src/wp-includes/script-loader.php Outdated
? sanitize_key( wp_unslash( $_REQUEST['action'] ) )
: 'login';

if ( 'login' !== $login_action || isset( $_REQUEST['interim-login'] ) ) {

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.

🤖 Comment by Claude Opus 5.5

The action is resolved differently here than in wp-login.php, in two ways.

  1. The check-your-email and password reset screens still prefetch. Before dispatching, wp-login.php switches $action to resetpass when $_GET['key'] is set, and to checkemail when $_GET['checkemail'] is set. The checkemail case shows a message with no login form, yet wp-login.php?checkemail=confirm (where a password reset request lands) prefetches the whole admin set here, because only $_REQUEST['action'] is read. The docblock's claim that the check-your-email flow is excluded does not hold.
  2. Unknown actions skip prefetching. wp-login.php falls back to login, and shows the login form, for any action that is not a default one and has no login_form_{$action} filter. Here, such an action returns early instead.

Suggested fix: read the $action global that wp-login.php has already resolved by the time login_head fires, rather than re-deriving it from $_REQUEST, so the two cannot drift. Tests should cover checkemail=confirm, key=… and an unknown action.

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.

🤖 Comment by Claude Opus 5.5

Addressed in 2b11401. The login screen now uses the $action and $interim_login globals that wp-login.php has already resolved, as login_header() does, instead of re-deriving the action from $_REQUEST.

As a result, the password reset (key) and check-your-email (checkemail) screens no longer prefetch, and an unrecognized action, which wp-login.php treats as the login form, now does. Nothing is prefetched when a plugin fires login_head outside wp-login.php, where the globals are not set. Tests cover each of these cases.

*/
$admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH );

if ( '' === $admin_path || ! str_starts_with( (string) wp_parse_url( $next_screen, PHP_URL_PATH ), $admin_path ) ) {

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.

🤖 Comment by Claude Opus 5.5

Only the path of redirect_to is compared, not the host. wp_validate_redirect() accepts any host in allowed_redirect_hosts, so on multisite a redirect_to pointing at another site's /wp-admin/ passes this check, and this site's assets are prefetched. The next screen requests them from a different host, so the prefetch is wasted.

Suggested fix: when $next_screen has a host, also require it to match the host of admin_url().

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.

🤖 Comment by Claude Opus 5.5

Addressed in 4e14337. An absolute redirect_to now also has to match the host and port of admin_url(); a relative one stays on this host. The port is included because wp_validate_redirect() compares only the host, so this site's host on another port would otherwise pass as well.

Tests cover another host allowed via allowed_redirect_hosts and this host on another port.

*/
$screen = get_current_screen();

if ( ! $screen instanceof WP_Screen || ! in_array( $screen->base, array( 'dashboard', 'edit' ), true ) ) {

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.

🤖 Comment by Claude Opus 5.5

The Network Admin and User Admin Dashboards also match. Their screen IDs are dashboard-network and dashboard-user, but their base is still dashboard. The editor is not the usual next stop from either, and admin_url( 'post-new.php' ) points at the current site's editor. A super admin passes the create_posts check, so every Network Admin Dashboard load would prefetch the editor stylesheets for nothing.

Suggested fix: match on $screen->id (dashboard, and edit or edit-{$post_type}) rather than $screen->base, or also check ! is_network_admin() && ! is_user_admin(), and add a test for the Network Admin Dashboard.

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.

🤖 Comment by Claude Opus 5.5

This finding was a false positive. WP_Screen::get() appends the -network and -user suffix to the base as well as to the ID, so the Network Admin and User Admin Dashboards have the bases dashboard-network and dashboard-user, not dashboard. Neither matches the base check here, so they never prefetched, and the same holds for network and user versions of edit.

No code change was needed. Test cases for both Dashboards were added alongside the Plugins screen in d4f6bf8, so that loosening the base check would be caught.


// A handle that only aliases other handles has no stylesheet of its own.
if ( is_string( $src ) && '' !== $src ) {
$urls[] = $dependencies->_css_href( $src, $dependencies->registered[ $handle ]->ver, $handle );

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.

🤖 Comment by Claude Opus 5.5

Style and script URLs reach the filter in different forms. _css_href() and get_rtl_href() return esc_url() output, which is HTML-escaped (& becomes &#038;), while get_src() returns esc_url_raw() output, which is not. So prefetch_admin_assets callbacks see mixed formats, and the filter docblock's promise that resources sharing an href are collapsed fails when a callback appends the plain form of a style URL that has more than one query argument. Printing is unaffected, since the final esc_url() leaves an already-escaped URL unchanged.

Suggested fix: decode the style hrefs, for example with WP_HTML_Decoder::decode_attribute(), before adding them to the list, so the filter sees plain URLs throughout.

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.

🤖 Comment by Claude Opus 5.5

Addressed in d40ea0f. The stylesheet URLs from _css_href() and get_rtl_href() are now decoded with WP_HTML_Decoder::decode_attribute() before being added to the list, so the prefetch_admin_assets filter receives plain URLs for stylesheets as well as scripts. The printed links are unchanged, since each URL is escaped again when printed.

A test gives a stylesheet a query string of its own and checks that the filter receives & rather than &#038;, and that a callback appending the plain form of that URL results in only one link.

westonruter and others added 5 commits September 27, 2026 08:45
Decide whether the login screen leads to the admin from the `$action`
and `$interim_login` globals that wp-login.php has already resolved, as
`login_header()` does, instead of re-deriving them from the request.
wp-login.php overrides the `action` parameter: a `key` switches to the
password reset form and `checkemail` to the check-your-email message,
and an action it does not recognize falls back to the login form. So
those two screens were prefetching the admin's assets without leading
to it, while an unrecognized action showed the login form without
prefetching. Nothing is printed either when a plugin fires `login_head`
outside wp-login.php, where the globals are not set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login screen compared only the path of `redirect_to` with the
admin's. But `wp_validate_redirect()` accepts any host in
`allowed_redirect_hosts`, such as another site on a multisite network,
so a login redirecting to that site's admin prefetched this site's
assets, which the other admin would request from its own host instead.
Also require an absolute `redirect_to` to match the admin's host and
port. A relative one stays on this host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests hardcoded `http://example.org` for an absolute `redirect_to`
pointing at the editor and at the front end, and for the editor URL
the admin screens are expected to pass to the filter. Build them with
`admin_url()` and `home_url()` instead, so the tests hold on a test site
configured with another host. Since data providers run before those can
be relied on, the two absolute `redirect_to` cases become tests of their
own, and the admin-screen provider supplies the post type rather than
the URL. The other allowed host becomes `another.example.net`, so that
it cannot coincide with the test site's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`WP_Screen` suffixes both the screen ID and the base of the Network
Admin and User Admin Dashboards with `-network` and `-user`, so they do
not match the `dashboard` base and are already excluded along with
every other admin screen. Cover them next to the Plugins screen so the
base check cannot be loosened without noticing.

Also use plain `string` for the values in two data providers' return
types, keeping `non-falsy-string` for the keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`WP_Styles::_css_href()` and `WP_Styles::get_rtl_href()` return URLs
escaped for an HTML attribute, with `&` as `&WordPress#38;`, while
`WP_Scripts::get_src()` returns them unescaped. So the
`prefetch_admin_assets` filter received the two in different forms, and
a callback appending the plain form of a stylesheet URL with more than
one query argument was not collapsed with it, despite what the filter
documents. Decode the stylesheet URLs before building the list. The
printed links are unchanged, since each URL is escaped again when
printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants