Skip to content

perf(build): avoid quadratic link scan in the preload helper - #23510

Merged
sapphi-red merged 8 commits into
vitejs:mainfrom
antur84:perf/vite-preload-dedupe-set
Sep 30, 2026
Merged

sapphi-red merged 8 commits into
vitejs:mainfrom
antur84:perf/vite-preload-dedupe-set

Conversation

@antur84

@antur84 antur84 commented Sep 17, 2026 •

Copy link
Copy Markdown

Hey

I found this little opportunity while looking at browser performance traces originally, I've run this patch in prod with measured (inline in my patch) improvement metrics across the population. Sharing in case it makes sense for everyone.

Before

The __vitePreload runtime helper checked whether each dependency was already preloaded by scanning every <link> in the document, backwards, once per unseen dependency. For a call with L links and U unseen dependencies that is O(U * (L + U)), since the helper appends a link for every miss. A large code-split app whose dynamic imports carry long dependency lists pays it on the main thread at boot.

After

Collect the existing link hrefs into two Sets (any rel for scripts, stylesheet only for CSS) and dedupe in O(1) per dependency, so a call costs O(L + U). The sets are built on the first dependency not yet in seen, so a call whose dependencies are all seen does no link work, as before. seen already guarantees a dependency reaches the dedupe at most once, so links the helper creates are not written back into the sets.

Behaviour is unchanged: same links, same order, same CSS load promises and rejection, same vite:preloadError path, same nonce handling.

Performence measurement

A large code-split app ships this change as a patch currently. The helper times its own main-thread cost per editor boot, and on a small random sample of page loads it also replays the removed per-dependency link scan read-only, so the same boot records what the stock scan would have cost on that device. Traffic across desktop and mobile browsers; about 900 <link>s in the document after boot.

Column Meaning
Calls Median __vitePreload calls per boot; times are summed over them
Stock p50 / p90 Main-thread ms the removed link scan would have cost on these boots, median / 90th percentile. Scan only, without even having to do all the work, so a lower bound
PR p50 / p90 Main-thread ms this PR's helper spent on the same boots, everything included (resolution, snapshot, link insertion)
Δ Stock minus PR, in ms saved per boot; conservative, since the PR column includes work stock also did outside the scan
Boot Calls Stock p50 PR p50 Δ p50 Stock p90 PR p90 Δ p90
Full page load 36 144 102 ${\color{green}-42}$ 580 418 ${\color{green}-162}$
In-app navigation 9 90 36 ${\color{green}-54}$ 472 202 ${\color{green}-270}$

antur84 and others added 3 commits September 15, 2026 13:31
The `__vitePreload` runtime helper checked whether each dependency was
already preloaded by scanning every <link> in the document, backwards, once
per unseen dependency. For a call with L links and U unseen dependencies that
is O(U * (L + U)), since the helper appends a link for every miss. A large
code-split app whose dynamic imports carry long dependency lists pays it on
the main thread at boot.

Collect the existing link hrefs into two Sets (any rel for scripts,
stylesheet only for CSS) and dedupe in O(1) per dependency, so a call costs
O(L + U). The sets are built on the first dependency not yet in `seen`, so a
call whose dependencies are all seen does no link work, as before. `seen`
already guarantees a dependency reaches the dedupe at most once, so links the
helper creates are not written back into the sets.

Behaviour is unchanged: same links, same order, same CSS load promises and
rejection, same vite:preloadError path, same nonce handling.

Adds a playground regression test for the case where the scan matches an
HTML modulepreload link. The entry already imports chunk.js, so a
codeSplitting group gives it its own chunk and a modulepreload link in the
built HTML; the dynamically loaded `about` module imports it too, so its
preload dep list would re-hint it. The test asserts a single modulepreload
link for that chunk before and after the dynamic import. It passes on main
as well, since the stock scan dedupes it too, and guards the rewrite: it
fails when the Set lookup or the lazy scan is removed. The config change is
scoped to the dist/normal suite; the sibling suites keep their own configs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adapt expected snapshot to new src length
@sapphi-red sapphi-red added p2-nice-to-have Not breaking anything but nice to have (priority) feat: build labels Sep 30, 2026
Comment thread packages/vite/src/node/plugins/importAnalysisBuild.ts

@sapphi-red sapphi-red left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks!

@sapphi-red
sapphi-red merged commit cf5c028 into vitejs:main Sep 30, 2026
28 of 29 checks passed
renovate Bot added a commit to andrei-picus-tink/auto-renovate that referenced this pull request Oct 3, 2026
| datasource | package | from  | to    |
| ---------- | ------- | ----- | ----- |
| npm        | vite    | 8.3.0 | 8.3.2 |


## [v8.3.2](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-832-2026-10-01-small)

##### Bug Fixes

- **build:** preload CSS correctly when `renderBuiltUrl` returns URLs with queries ([#23611](vitejs/vite#23611)) ([64e0a21](vitejs/vite@64e0a21))
- **bundled-dev:** serve lazy chunk sourcemaps ([#23026](vitejs/vite#23026)) ([eb7aa9a](vitejs/vite@eb7aa9a))
- **bundled-dev:** serve the rolldown runtime from the installed rolldown ([#23568](vitejs/vite#23568)) ([bc598a6](vitejs/vite@bc598a6))
- **deps:** update all non-major dependencies ([#23601](vitejs/vite#23601)) ([9944fa6](vitejs/vite@9944fa6))
- **deps:** update rolldown-related dependencies ([#23602](vitejs/vite#23602)) ([88c1741](vitejs/vite@88c1741))
- **html:** resolve percent-encoded srcset urls ([#23609](vitejs/vite#23609)) ([53f1ce7](vitejs/vite@53f1ce7))
- limit size of object and array printing via `forwardConsole` ([#23565](vitejs/vite#23565)) ([e64a587](vitejs/vite@e64a587))
- merge `build.rolldownOptions.output.minify` correctly ([#23536](vitejs/vite#23536)) ([bba3bb8](vitejs/vite@bba3bb8))
- **optimize-deps:** avoid "unsupported" warnings for browser:false mappings ([#23590](vitejs/vite#23590)) ([5e4b9ca](vitejs/vite@5e4b9ca))
- **optimizer:** preserve excluded optional peer require fallbacks ([#23600](vitejs/vite#23600)) ([a2bd6fa](vitejs/vite@a2bd6fa))
- pass queries to `renderBuiltUrl` ([#23586](vitejs/vite#23586)) ([744269e](vitejs/vite@744269e))
- **server:** handle file watcher errors without crashing ([#23503](vitejs/vite#23503)) ([6894f5c](vitejs/vite@6894f5c))
- **server:** release previous environments after initialization ([#23499](vitejs/vite#23499)) ([5a3a010](vitejs/vite@5a3a010))
- **ssr:** encode whitespace in module runner sourceURL ([#23513](vitejs/vite#23513)) ([bbc8812](vitejs/vite@bbc8812))
- **worker:** align worker urls in client and server when using terser ([#23614](vitejs/vite#23614)) ([24bd331](vitejs/vite@24bd331))

##### Performance Improvements

- avoid encoding intermediate source maps ([#23461](vitejs/vite#23461)) ([89574f6](vitejs/vite@89574f6))
- **build:** avoid quadratic link scan in the preload helper ([#23510](vitejs/vite#23510)) ([cf5c028](vitejs/vite@cf5c028))
- only register time middleware when debug logging is enabled ([#23621](vitejs/vite#23621)) ([94d0080](vitejs/vite@94d0080))

##### Documentation

- fix dead og-image PNG links in vite6/vite7 changelog entries ([#23594](vitejs/vite#23594)) ([1929b4c](vitejs/vite@1929b4c))

##### Miscellaneous Chores

- **deps:** update vitest monorepo to v5 ([#23604](vitejs/vite#23604)) ([24339f4](vitejs/vite@24339f4))

##### Code Refactoring

- use `tinyexec` instead of `cross-spawn` ([#23583](vitejs/vite#23583)) ([db915e5](vitejs/vite@db915e5))

##### Tests

- **bundled-dev:** accept a rolldown dev runtime with no helper imports ([#23606](vitejs/vite#23606)) ([634745d](vitejs/vite@634745d))


## [v8.3.1](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-831-2026-09-24-small)

##### Bug Fixes

- **deps:** update all non-major dependencies ([#23482](vitejs/vite#23482)) ([3c752c8](vitejs/vite@3c752c8))
- **deps:** update all non-major dependencies ([#23537](vitejs/vite#23537)) ([e8990c4](vitejs/vite@e8990c4))
- **deps:** update rolldown-related dependencies ([#23483](vitejs/vite#23483)) ([9aecbbf](vitejs/vite@9aecbbf))
- handle `server.ws: false` in mergeConfig ([#23511](vitejs/vite#23511)) ([f68c0d5](vitejs/vite@f68c0d5))
- merge `build.rolldownOptions.output.comments` correctly ([#23514](vitejs/vite#23514)) ([4aba8d8](vitejs/vite@4aba8d8))
- **optimizer:** don't skip imports whose binding starts with type ([#23540](vitejs/vite#23540)) ([39330f4](vitejs/vite@39330f4))
- **optimizer:** resolve pending discovered dep processing on close before init ([#23567](vitejs/vite#23567)) ([5f89433](vitejs/vite@5f89433))
- **server:** avoid reinitializing watcher when adding file after server close ([#23572](vitejs/vite#23572)) ([6f831f9](vitejs/vite@6f831f9))
- **sourcemap:** skip URL source roots when injecting sources content ([#23519](vitejs/vite#23519)) ([04fc30a](vitejs/vite@04fc30a))

##### Miscellaneous Chores

- merge prereleases in changelog ([#23466](vitejs/vite#23466)) ([99bd9d1](vitejs/vite@99bd9d1))
- **optimizer:** add debug log when waiting for dep before init ([#23566](vitejs/vite#23566)) ([63567c7](vitejs/vite@63567c7))
- update `optimizeDeps.include` comment ([#23489](vitejs/vite#23489)) ([6a84c72](vitejs/vite@6a84c72))

##### Code Refactoring

- assets regexp use non-capture ([#23491](vitejs/vite#23491)) ([f4b4431](vitejs/vite@f4b4431))
- remove duplicate configurations ([#23532](vitejs/vite#23532)) ([9abd99b](vitejs/vite@9abd99b))
- replace `find` with `some` ([#23554](vitejs/vite#23554)) ([af7cdf6](vitejs/vite@af7cdf6))
renovate Bot added a commit to andrei-picus-tink/auto-renovate that referenced this pull request Oct 4, 2026
| datasource | package | from  | to    |
| ---------- | ------- | ----- | ----- |
| npm        | vite    | 8.3.0 | 8.3.2 |


## [v8.3.2](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-832-2026-10-01-small)

##### Bug Fixes

- **build:** preload CSS correctly when `renderBuiltUrl` returns URLs with queries ([#23611](vitejs/vite#23611)) ([64e0a21](vitejs/vite@64e0a21))
- **bundled-dev:** serve lazy chunk sourcemaps ([#23026](vitejs/vite#23026)) ([eb7aa9a](vitejs/vite@eb7aa9a))
- **bundled-dev:** serve the rolldown runtime from the installed rolldown ([#23568](vitejs/vite#23568)) ([bc598a6](vitejs/vite@bc598a6))
- **deps:** update all non-major dependencies ([#23601](vitejs/vite#23601)) ([9944fa6](vitejs/vite@9944fa6))
- **deps:** update rolldown-related dependencies ([#23602](vitejs/vite#23602)) ([88c1741](vitejs/vite@88c1741))
- **html:** resolve percent-encoded srcset urls ([#23609](vitejs/vite#23609)) ([53f1ce7](vitejs/vite@53f1ce7))
- limit size of object and array printing via `forwardConsole` ([#23565](vitejs/vite#23565)) ([e64a587](vitejs/vite@e64a587))
- merge `build.rolldownOptions.output.minify` correctly ([#23536](vitejs/vite#23536)) ([bba3bb8](vitejs/vite@bba3bb8))
- **optimize-deps:** avoid "unsupported" warnings for browser:false mappings ([#23590](vitejs/vite#23590)) ([5e4b9ca](vitejs/vite@5e4b9ca))
- **optimizer:** preserve excluded optional peer require fallbacks ([#23600](vitejs/vite#23600)) ([a2bd6fa](vitejs/vite@a2bd6fa))
- pass queries to `renderBuiltUrl` ([#23586](vitejs/vite#23586)) ([744269e](vitejs/vite@744269e))
- **server:** handle file watcher errors without crashing ([#23503](vitejs/vite#23503)) ([6894f5c](vitejs/vite@6894f5c))
- **server:** release previous environments after initialization ([#23499](vitejs/vite#23499)) ([5a3a010](vitejs/vite@5a3a010))
- **ssr:** encode whitespace in module runner sourceURL ([#23513](vitejs/vite#23513)) ([bbc8812](vitejs/vite@bbc8812))
- **worker:** align worker urls in client and server when using terser ([#23614](vitejs/vite#23614)) ([24bd331](vitejs/vite@24bd331))

##### Performance Improvements

- avoid encoding intermediate source maps ([#23461](vitejs/vite#23461)) ([89574f6](vitejs/vite@89574f6))
- **build:** avoid quadratic link scan in the preload helper ([#23510](vitejs/vite#23510)) ([cf5c028](vitejs/vite@cf5c028))
- only register time middleware when debug logging is enabled ([#23621](vitejs/vite#23621)) ([94d0080](vitejs/vite@94d0080))

##### Documentation

- fix dead og-image PNG links in vite6/vite7 changelog entries ([#23594](vitejs/vite#23594)) ([1929b4c](vitejs/vite@1929b4c))

##### Miscellaneous Chores

- **deps:** update vitest monorepo to v5 ([#23604](vitejs/vite#23604)) ([24339f4](vitejs/vite@24339f4))

##### Code Refactoring

- use `tinyexec` instead of `cross-spawn` ([#23583](vitejs/vite#23583)) ([db915e5](vitejs/vite@db915e5))

##### Tests

- **bundled-dev:** accept a rolldown dev runtime with no helper imports ([#23606](vitejs/vite#23606)) ([634745d](vitejs/vite@634745d))


## [v8.3.1](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-831-2026-09-24-small)

##### Bug Fixes

- **deps:** update all non-major dependencies ([#23482](vitejs/vite#23482)) ([3c752c8](vitejs/vite@3c752c8))
- **deps:** update all non-major dependencies ([#23537](vitejs/vite#23537)) ([e8990c4](vitejs/vite@e8990c4))
- **deps:** update rolldown-related dependencies ([#23483](vitejs/vite#23483)) ([9aecbbf](vitejs/vite@9aecbbf))
- handle `server.ws: false` in mergeConfig ([#23511](vitejs/vite#23511)) ([f68c0d5](vitejs/vite@f68c0d5))
- merge `build.rolldownOptions.output.comments` correctly ([#23514](vitejs/vite#23514)) ([4aba8d8](vitejs/vite@4aba8d8))
- **optimizer:** don't skip imports whose binding starts with type ([#23540](vitejs/vite#23540)) ([39330f4](vitejs/vite@39330f4))
- **optimizer:** resolve pending discovered dep processing on close before init ([#23567](vitejs/vite#23567)) ([5f89433](vitejs/vite@5f89433))
- **server:** avoid reinitializing watcher when adding file after server close ([#23572](vitejs/vite#23572)) ([6f831f9](vitejs/vite@6f831f9))
- **sourcemap:** skip URL source roots when injecting sources content ([#23519](vitejs/vite#23519)) ([04fc30a](vitejs/vite@04fc30a))

##### Miscellaneous Chores

- merge prereleases in changelog ([#23466](vitejs/vite#23466)) ([99bd9d1](vitejs/vite@99bd9d1))
- **optimizer:** add debug log when waiting for dep before init ([#23566](vitejs/vite#23566)) ([63567c7](vitejs/vite@63567c7))
- update `optimizeDeps.include` comment ([#23489](vitejs/vite#23489)) ([6a84c72](vitejs/vite@6a84c72))

##### Code Refactoring

- assets regexp use non-capture ([#23491](vitejs/vite#23491)) ([f4b4431](vitejs/vite@f4b4431))
- remove duplicate configurations ([#23532](vitejs/vite#23532)) ([9abd99b](vitejs/vite@9abd99b))
- replace `find` with `some` ([#23554](vitejs/vite#23554)) ([af7cdf6](vitejs/vite@af7cdf6))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat: build p2-nice-to-have Not breaking anything but nice to have (priority)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants