Skip to content

Vite and Rsbuild build-time metrics are not apples-to-apples #53

Description

@joeldickson

Feedback came in from a review of the current Vite and Rsbuild collectors, ahead of another round of data collection. I verified both claims against master by instrumenting real builds. The first claim is correct. The second is wrong as stated, but there is a real dev-startup asymmetry underneath it — pointing the other way. I also found a related bug in the Vite prebundled flag while checking.

Versions used: agoda-devfeedback-vite2@2.1.0, agoda-devfeedback-rsbuild@2.0.9, vite@5.4.14, @rsbuild/core@1.3.5, @rspack/core@1.3.4.


1. Production build time: confirmed, Vite systematically under-reports

The Vite plugin reports buildEnd - buildStart:

https://github.com/agoda-com/devfeedback-js/blob/master/packages/vite-plugin/src/lib/vite-build-stats-plugin.ts#L329

Rollup's buildEnd fires when the module graph is complete — before renderStart, renderChunk (minification), generateBundle, and writeBundle. So everything after the transform phase is excluded, even though the value is sent later from closeBundle.

I timed the hooks on a 301-module synthetic project:

minifier reported (buildEnd - buildStart) actual (through closeBundle) excluded
esbuild (Vite default) 235 ms 285 ms 51 ms (18%)
terser 230 ms 631 ms 401 ms (64%)

Hook timeline for the terser run, ms from buildStart:

     0  buildStart
   230  buildEnd          <- reporting stops here
   232  renderStart
   624  generateBundle
   626  writeBundle
   630  closeBundle       <- vite itself printed "built in 633ms"

Real apps will sit above the 18% floor, since CSS, sourcemaps, multiple chunks, and gzip-size reporting all land in the excluded phases.

Rsbuild has no equivalent gap. It uses stats.endTime - stats.startTime:

https://github.com/agoda-com/devfeedback-js/blob/master/packages/rspack-plugin/src/lib/rsbuild-stats-plugin.ts#L169-L171

Rspack stamps compilation.endTime after the native build() completes, which covers code generation, minification, and emitting assets. Verified in onAfterBuild: at the moment endTime was stamped, the output was already minified and already on disk.

stats.endTime - stats.startTime: 97 ms
wall clock to onAfterBuild:      150 ms
assets already written to disk when endTime was stamped: true ["index.588ce43e.js"]
output is minified: true (63185 bytes)

Net effect: for the same work, type: 'vite' covers transform only and type: 'rsbuild' covers the full pipeline. Any dashboard comparing them flatters Vite by a bundler-config-dependent margin.

Suggested fix

Measure the Vite production build through closeBundle, i.e. Date.now() - buildStart at report time rather than buildEnd - buildStart. If the transform/render split is worth keeping, send it as an extra field instead of narrowing the headline number.


2. Dev startup: the claim as written is incorrect, but the spans still aren't comparable

The claim was that Rsbuild reports the first full compilation while Vite reports no equivalent server-ready or first-app-ready event. Vite does report both, and has since #48:

The real problems are different, and one of them is a gap on the Rsbuild side:

(a) phase: 'devserver' means "socket is up" on both sides, and that is almost nothing on Vite. I measured Vite at 21–25 ms to listening, and Rsbuild's onAfterStartDevServer at 129 ms — which also fires before the first compile finishes, not after:

    66 ms  onBeforeStartDevServer
   129 ms  onAfterStartDevServer   <- devserver event
   162 ms  startDevServer() resolved
   217 ms  onDevCompileDone (stats span = 87 ms)   <- rsbuild build event

So the devserver phase is roughly like-for-like in definition, but it captures a meaningful share of Rsbuild's startup cost and essentially none of Vite's, because Vite defers all compilation until a request arrives. Comparing bundlers on devserver alone will read as a ~5x win for Vite that reflects nothing a developer waits for.

(b) Rsbuild reports the first full compilation; Vite structurally cannot. onDevCompileDone emits a type: 'rsbuild' build event for the first compile. Vite has no whole-app compile in dev, so there is no counterpart and there should not be one. The honest cross-bundler measure of "time until the app is usable" is clientready.

(c) clientready only exists as a first-class event on the Vite side, and the two time origins differ. Rsbuild pushes clientReady into devFeedback[] inside RspackBuildData rather than emitting a CommandBuildData with phase: 'clientready':

https://github.com/agoda-com/devfeedback-js/blob/master/packages/rspack-plugin/src/lib/rsbuild-stats-plugin.ts#L154

Worse, the reference points don't match even after you dig the value out:

  • Vite clientready timeTaken = Date.now() - serverStart, i.e. from dev-server start to browser idle.
  • Rsbuild clientReady elapsedMs = performance.now(), i.e. from page navigation start to browser idle — it excludes everything before the browser opened the page.

These two numbers cannot be compared, which leaves no usable apples-to-apples dev-startup metric today.

Suggested fix

  1. Emit phase: 'clientready' from the Rsbuild plugin as a CommandBuildData, with timeTaken measured from devServerStart so it matches Vite's origin. Keep the raw performance.now() value as domContentLoadedMs / firstContentfulPaintMs detail rather than as the headline number.
  2. Treat clientready as the canonical cross-bundler dev-startup metric and stop comparing devserver across bundlers, or document clearly that devserver is "socket up" and not a dev-startup number.

3. Bug found while verifying: Vite's prebundled flag can never report true

prebundled is computed by comparing node_modules/.vite/deps/_metadata.json mtime before config resolution against its mtime at listening:

https://github.com/agoda-com/devfeedback-js/blob/master/packages/vite-plugin/src/lib/vite-build-stats-plugin.ts#L147-L161

Dependency prebundling is request-triggered, so it hasn't run yet at listening. With no browser request at all, the metadata file was never written even after 15 s of waiting. With a request, it lands well after the span closed:

     0 ms  configureServer          <- depsMetaMtimeBefore read here
    21 ms  httpServer listening     <- prebundled computed here
    69 ms  browser requested / (index.html)
   281 ms  browser requested /src/main.js
   333 ms  deps/_metadata.json written   <- prebundling actually finished

So on a warm cache both reads match and it reports false; on a cold cache both reads are undefined and it reports nothing. The comment in the code says prebundling is "usually the dominant cold-start cost", which is exactly the cost this flag is currently blind to.

Suggested fix

Sample the mtime later — on the clientready report, or after the first successful transform request — rather than at listening.


Repro

Each number above came from a small standalone probe rather than from the plugins themselves, to keep the measurement independent of the code under test:

  • Vite production hooks: a plugin logging Date.now() in buildStart, buildEnd, renderStart, generateBundle, writeBundle, closeBundle, on 300 generated modules, run once with minify: 'esbuild' and once with minify: 'terser'.
  • Rsbuild production: onAfterBuild reading stats.endTime - stats.startTime, then checking dist/static/js on disk and whether the emitted file is minified.
  • Rsbuild dev: logging onBeforeStartDevServer, onAfterStartDevServer, and onDevCompileDone.
  • Vite dev: logging configureServer and httpServer listening, then polling for node_modules/.vite/deps/_metadata.json, with and without an HTTP request to the server.

Happy to push the probes into the repo as a benchmarks/ or test fixture if that's useful for guarding the fix.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions