vercel / vercel/next.js

Turbopack: CSS HMR sometimes lags one revision behind edits

Open
#93,052 2 comments 4 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

CSS Turbopack
Dominant language
JavaScript
Stars
142k
Forks
32.5k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/Dougg11/nextjs-bug-repro

To Reproduce
  1. npm install and npm run dev (Turbopack default).
  2. Open http://localhost:3000 in a browser and let the page render once. Inspect the served CSS chunk and note the value of any Tailwind-driven property (e.g. border-radius of an element).
  3. Edit app/globals.css (or whichever CSS file the page loads) and change a single property — for example, change rounded-lg (8px) to rounded-2xl (16px) on the demo element, or directly modify a border-radius value inside @layer base.
  4. Save the file.
  5. Reload the page without making any further edits and re-inspect the CSS chunk.

Observed: the served CSS reflects the previous edit, not the one you just saved.

If you save again with no changes (or make a second small edit), the previously-saved value finally shows up — but the new edit is now itself one revision behind.

A scripted version of this loop that triggers the bug deterministically in ~1.5s is in the repro repo under scripts/repro_css_hmr.py.

Current vs. Expected behavior

Expected: after a save to a CSS file, the next browser reload (or HMR push) serves CSS that reflects the saved content.

Current: the served CSS reflects the previous version of the file. Every subsequent save makes the previous edit visible while leaving the latest edit one revision behind. With next dev --webpack the same project behaves correctly.

Underlying cause (verified): for each single save of a CSS file that has any PostCSS plugin reporting at least one file dependency, Turbopack invokes the PostCSS transform twice:

  1. Invocation 1 receives an out-of-date content snapshot via the worker IPC, but the live fs.statSync of the input path inside the worker returns the new mtime.
  2. Invocation 2 (a few ms later) receives the up-to-date content, but fs.statSync returns the same mtime as invocation 1.

Plugins like @tailwindcss/postcss cache compiled output keyed on mtimeMs. Invocation 1 caches the stale output against the new mtime; invocation 2 sees mtime unchanged, hits the cache, and returns the stale output to Turbopack. The committed/served CSS chunk is therefore always one revision behind.

Reproduces with isolated edits ≥30s apart, so this is not a debounce/coalescing issue with rapid edits — it's a per-save invariant violation.

Provide environment information
Tested on:

- `next@16.x` (canary), Turbopack default in dev.
- macOS 25.0.0 (darwin) on APFS.
- Node 20.x.
- Reproduced regardless of editor (VS Code, vim) and write strategy (atomic rename, truncate-then-write, single `writeFileSync`).
- Does **not** reproduce with `next dev --webpack`.
Which area(s) are affected? (Select all that apply)

Turbopack, CSS

Which stage(s) are affected? (Select all that apply)

next dev (local)

Additional context

The bug is in Turbopack's PostCSS pipeline; Tailwind is just a high-profile victim. Below is a no-op PostCSS plugin that exhibits the double-invocation directly. It does no transformation — it only logs what input it received and reports a single neighboring file as a dependency.

postcss-noop.cjs:

const fs = require('fs');
const path = require('path');

module.exports = () => ({
  postcssPlugin: 'noop-with-deps',
  Once(root, { result }) {
    const from = result.opts.from || '';
    const inputText = root.toString();
    const liveDisk = fs.readFileSync(from, 'utf8');
    const liveMtime = fs.statSync(from).mtimeMs;

    fs.appendFileSync(
      '/tmp/noop.log',
      `[${Date.now()}] from=${path.basename(from)} ` +
      `match=${inputText === liveDisk} mtime=${liveMtime} ` +
      `input_len=${inputText.length} disk_len=${liveDisk.length}\n`
    );

    const sibling = path.join(path.dirname(from), 'page.tsx');
    if (fs.existsSync(sibling)) {
      result.messages.push({
        type: 'dependency',
        plugin: 'noop-with-deps',
        file: sibling,
        parent: from,
      });
    }
  },
});
module.exports.postcss = true;

postcss.config.mjs:

export default { plugins: { '/abs/path/to/postcss-noop.cjs': {} } };

Steps:

  1. npm run dev, load / once, then wait ≥30s.
  2. Edit app/globals.css and save once.
  3. cat /tmp/noop.log.

Observed: two log lines per single save. The first has match=false (the content the worker received does not equal the disk content) with the new mtime. The second has match=true with the same mtime.

Expected: one log line per save with match=true.

Removing the result.messages.push(...) block produces exactly one invocation per save with match=true. The second invocation only appears when at least one dependency is reported.

Components and their roles in the race
Component Role
Turbopack file watcher Detects writes, invalidates the Vc<FileContent> reactive cell.
transform_postcss task (turbopack-node/src/transforms/postcss.rs) Reads source.content(), spawns the Node worker, passes (content_string, css_path, source_map_flag) as args.
Node worker (turbopack-node/js/src/transforms/postcss.ts) Receives args, runs the PostCSS pipeline against content with from: cssPath.
Downstream PostCSS plugin (e.g. @tailwindcss/postcss) Caches compiled output keyed on fs.statSync(cssPath).mtimeMs.

The worker passes the content snapshot via IPC but the path is a real path. Plugins reasonably assume mtime(path) corresponds to the content they were just given. Turbopack does not enforce or signal that invariant, and per-save scheduling can produce two invocations where invocation 1 violates it.

Possible fixes (happy to PR whichever direction maintainers prefer)

Ranked by feasibility and blast radius:

  1. Pass a content version to the worker. Add a 4th arg to the args vec in process() (e.g. xxh3_64(content)), surface it on result.opts.contentVersion in the worker. Plugins that want to be correct under Turbopack can key their cache on it instead of mtime. Strictly additive API. Companion change in tailwindlabs/tailwindcss to opt in.
  2. Cancel in-flight transform tasks when their inputs invalidate. Structurally the cleanest fix — the stale invocation never completes — but requires task-cancellation support in turbo-tasks.
  3. Coalesce invalidations within a small window (~16ms). Quick win for fast-edit case, but does not fix the 30s-isolated-edit case, so partial only.
  4. Read-set verification. After the worker returns, re-read source.content() and re-run if it differs. Correct but doubles work and ping-pongs on bursty saves.
  5. Virtualize fs in the worker so live disk state is inaccessible. Most semantically correct, very large change.
Plugin-side workaround available today

For users hitting this with @tailwindcss/postcss, a patch-package patch that adds a content-equality check inside the existing mtime-loop in the plugin's Once handler restores correct behavior under Turbopack with no Turbopack changes required. Conceptually:

const currentInput = root.toString();
if (cache.lastInput !== currentInput) {
  rebuild = 'full';
  cache.lastInput = currentInput;
}

The full patch is included in the linked repro repo under patches/@tailwindcss+postcss+4.2.2.patch and applied automatically via a postinstall hook.

Related
  • tailwindlabs/tailwindcss#17554 — earlier Tailwind-side patch that excluded the input CSS file from self-reported deps. Mitigates one path to this bug; does not fix the general case.
  • tailwindlabs/tailwindcss#17612 — follow-up.
  • vercel/next.js#87884 — user-facing symptom report.
Out of scope (flagging for separate investigation)

Under very rapid saves it is plausible that Turbopack drops invalidations entirely, causing edits to be permanently lost rather than merely delayed by one revision. We did not characterize that behavior here. The mechanism above explains the "one revision behind" pattern observed even at 30-second isolation, which is sufficient for this issue.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with turbopack-node/src/transforms/postcss.rs and turbopack-node/js/src/transforms/postcss.ts, then run scripts/repro_css_hmr.py from the linked reproduction. Trace the two PostCSS invocations when a dependency is reported and verify that a saved CSS revision, including Tailwind-driven output, is served on the next reload without regressing dependency handling.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, next.js, rust, tailwindcss
Domain
build-system, devtools, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.