nextjs - 💡(How to fix) Fix Turbopack: CSS HMR sometimes lags one revision behind edits

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Fix Action

Fix / Workaround

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:

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.

Code Example

Tested on:

- `[email protected]` (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`.

---

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;

---

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

---

const currentInput = root.toString();
if (cache.lastInput !== currentInput) {
  rebuild = 'full';
  cache.lastInput = currentInput;
}
RAW_BUFFERClick to expand / collapse

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:

- `[email protected]` (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

ComponentRole
Turbopack file watcherDetects 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.

extent analysis

TL;DR

The most likely fix for the issue is to pass a content version to the worker, allowing plugins to key their cache on it instead of mtime, which can be achieved by modifying the transform_postcss task in Turbopack.

Guidance

  • Identify the root cause of the issue, which is the double invocation of the PostCSS transform in Turbopack, causing the served CSS to reflect the previous version of the file.
  • Consider implementing a content versioning system, where a unique identifier is passed to the worker, allowing plugins to cache compiled output based on this version instead of mtime.
  • As a temporary workaround, apply the provided patch-package patch to @tailwindcss/postcss to add a content-equality check, restoring correct behavior under Turbopack.
  • Investigate the feasibility of canceling in-flight transform tasks when their inputs invalidate, which could provide a more structural fix.
  • Review the coalescing of invalidations within a small window as a potential quick win, but note that it may not fix the issue for isolated edits.

Example

A possible implementation of the content versioning system could involve modifying the transform_postcss task to pass a hash of the content as an additional argument to the worker, and then using this hash as a key for caching in plugins.

// In transform_postcss task
const contentHash = xxh3_64(content);
const args = [content, cssPath, sourceMapFlag, contentHash];
// In PostCSS plugin
const contentVersion = result.opts.contentVersion;
// Use contentVersion as cache key instead of mtime

Notes

The provided postcss-noop.cjs plugin and postcss.config.mjs configuration can be used to reproduce and test the issue. The patch-package patch for @tailwindcss/postcss can be applied as a temporary workaround.

Recommendation

Apply the workaround by patching @tailwindcss/postcss with the provided patch-package patch, as it provides a relatively simple and effective solution to the issue. However, it is recommended to also investigate and implement a more permanent fix, such as passing a content version to the worker, to ensure correct behavior under Turbopack.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

nextjs - 💡(How to fix) Fix Turbopack: CSS HMR sometimes lags one revision behind edits