Repository: vercel/next.js
Pull request: perf: Use synchronous IO when cleaning `distDir` in `next dev` and `next build`

This is blocking the critical path for starting `next build` (on both webpack and Turbopack) and `next dev` (only on webpack).

## Testing

Run

```
pnpm next build --turbopack --experimental-build-mode compile
```

a couple of times in `~/front/apps/conf-2025` (small-medium size app).

### Before

![Screenshot 2025-10-02 at 3.09.31 PM.png](https://app.graphite.dev/user-attachments/assets/ad56ebc1-a9dd-4b18-ba51-50435c9e6584.png)

### After

![Screenshot 2025-10-02 at 3.03.25 PM.png](https://app.graphite.dev/user-attachments/assets/39170ed1-7379-48ac-aeed-638b57322a6a.png)



Closes PACK-5619

Changed files:
filename: packages/next/src/build/index.ts
patch: @@ -47,7 +47,7 @@ import type {
   RouteHas,
 } from '../lib/load-custom-routes'
 import { nonNullable } from '../lib/non-nullable'
-import { recursiveDelete } from '../lib/recursive-delete'
+import { recursiveDeleteSyncWithAsyncRetries } from '../lib/recursive-delete'
 import { verifyPartytownSetup } from '../lib/verify-partytown-setup'
 import {
   BUILD_ID_FILE,
@@ -1123,7 +1123,11 @@ export default async function build(
       }
 
       if (config.cleanDistDir && !isGenerateMode) {
-        await recursiveDelete(distDir, /^(cache|dev)/)
+        await nextBuildSpan
+          .traceChild('clean')
+          .traceAsyncFn(() =>
+            recursiveDeleteSyncWithAsyncRetries(distDir, /^(cache|dev)/)
+          )
       }
 
       if (appDir && 'exportPathMap' in config) {

filename: packages/next/src/lib/recursive-delete.ts
patch: @@ -1,6 +1,6 @@
-import type { Dirent } from 'fs'
-import { promises } from 'fs'
-import { join, isAbsolute, dirname } from 'path'
+import type { Dirent } from 'node:fs'
+import * as fs from 'node:fs'
+import { join, isAbsolute, dirname } from 'node:path'
 import isError from './is-error'
 import { wait } from './wait'
 
@@ -23,16 +23,16 @@ export function calcBackoffMs(attempt: number): number {
   return Math.min(INITIAL_RETRY_MS * Math.pow(2, attempt), MAX_RETRY_MS)
 }
 
-const unlinkPath = async (
+function unlinkPath(
   p: string,
   isDir = false,
   attempt = 0
-): Promise<void> => {
+): Promise<void> | void {
   try {
     if (isDir) {
-      await promises.rmdir(p)
+      fs.rmdirSync(p)
     } else {
-      await promises.unlink(p)
+      fs.unlinkSync(p)
     }
   } catch (e) {
     const code = isError(e) && e.code
@@ -44,9 +44,11 @@ const unlinkPath = async (
       attempt < MAX_RETRIES
     ) {
       // retrying is unlikely to succeed on POSIX platforms, but Windows can
-      // fail due to temporarily-open files or due to
-      await wait(calcBackoffMs(attempt))
-      return unlinkPath(p, isDir, attempt + 1)
+      // fail due to temporarily-open files
+      return (async () => {
+        await wait(calcBackoffMs(attempt))
+        return unlinkPath(p, isDir, attempt + 1)
+      })()
     }
 
     if (code === 'ENOENT') {
@@ -58,9 +60,16 @@ const unlinkPath = async (
 }
 
 /**
- * Recursively delete directory contents
+ * Recursively delete directory contents.
+ *
+ * This is used when cleaning the `distDir`, and is part of the critical path
+ * for starting the server, so we use synchronous file IO, as we're always
+ * blocked on it anyways.
+ *
+ * Despite using sync IO, the function signature is still `async` because we
+ * asynchronously perform retries.
  */
-export async function recursiveDelete(
+export async function recursiveDeleteSyncWithAsyncRetries(
   /** Directory to delete the contents of */
   dir: string,
   /** Exclude based on relative file path */
@@ -70,7 +79,7 @@ export async function recursiveDelete(
 ): Promise<void> {
   let result
   try {
-    result = await promises.readdir(dir, { withFileTypes: true })
+    result = fs.readdirSync(dir, { withFileTypes: true })
   } catch (e) {
     if (isError(e) && e.code === 'ENOENT') {
       return
@@ -88,10 +97,10 @@ export async function recursiveDelete(
       const isSymlink = part.isSymbolicLink()
 
       if (isSymlink) {
-        const linkPath = await promises.readlink(absolutePath)
+        const linkPath = fs.readlinkSync(absolutePath)
 
         try {
-          const stats = await promises.stat(
+          const stats = fs.statSync(
             isAbsolute(linkPath)
               ? linkPath
               : join(dirname(absolutePath), linkPath)
@@ -105,7 +114,7 @@ export async function recursiveDelete(
 
       if (isNotExcluded) {
         if (!isSymlink && isDirectory) {
-          await recursiveDelete(absolutePath, exclude, pp)
+          await recursiveDeleteSyncWithAsyncRetries(absolutePath, exclude, pp)
         }
         return unlinkPath(absolutePath, !isSymlink && isDirectory)
       }

filename: packages/next/src/server/dev/hot-reloader-webpack.ts
patch: @@ -32,7 +32,7 @@ import getBaseWebpackConfig, {
   loadProjectInfo,
 } from '../../build/webpack-config'
 import { APP_DIR_ALIAS, WEBPACK_LAYERS } from '../../lib/constants'
-import { recursiveDelete } from '../../lib/recursive-delete'
+import { recursiveDeleteSyncWithAsyncRetries } from '../../lib/recursive-delete'
 import {
   BLOCKED_PAGES,
   CLIENT_STATIC_FILES_RUNTIME_MAIN,
@@ -634,7 +634,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
     return span
       .traceChild('clean')
       .traceAsyncFn(() =>
-        recursiveDelete(join(this.dir, this.config.distDir), /^cache/)
+        recursiveDeleteSyncWithAsyncRetries(
+          join(this.dir, this.config.distDir),
+          /^cache/
+        )
       )
   }
 

filename: test/unit/recursive-delete.test.ts
patch: @@ -1,6 +1,9 @@
 /* eslint-env jest */
 import fs from 'fs-extra'
-import { recursiveDelete, calcBackoffMs } from 'next/dist/lib/recursive-delete'
+import {
+  recursiveDeleteSyncWithAsyncRetries,
+  calcBackoffMs,
+} from 'next/dist/lib/recursive-delete'
 import { recursiveReadDir } from 'next/dist/lib/recursive-readdir'
 import { recursiveCopy } from 'next/dist/lib/recursive-copy'
 import { join } from 'path'
@@ -9,7 +12,7 @@ const resolveDataDir = join(__dirname, 'isolated', '_resolvedata')
 const testResolveDataDir = join(__dirname, 'isolated', 'test_resolvedata')
 const testpreservefileDir = join(__dirname, 'isolated', 'preservefiles')
 
-describe('recursiveDelete', () => {
+describe('recursiveDeleteSyncWithAsyncRetries', () => {
   if (process.platform === 'win32') {
     it('should skip on windows to avoid symlink issues', () => {})
     return
@@ -20,11 +23,11 @@ describe('recursiveDelete', () => {
     try {
       await recursiveCopy(resolveDataDir, testResolveDataDir)
       await fs.symlink('./aa', join(testResolveDataDir, 'symlink'))
-      await recursiveDelete(testResolveDataDir)
+      await recursiveDeleteSyncWithAsyncRetries(testResolveDataDir)
       const result = await recursiveReadDir(testResolveDataDir)
       expect(result.length).toBe(0)
     } finally {
-      await recursiveDelete(testResolveDataDir)
+      await recursiveDeleteSyncWithAsyncRetries(testResolveDataDir)
     }
   })
 
@@ -35,13 +38,13 @@ describe('recursiveDelete', () => {
         overwrite: true,
       })
       // preserve cache dir
-      await recursiveDelete(testpreservefileDir, /^cache/)
+      await recursiveDeleteSyncWithAsyncRetries(testpreservefileDir, /^cache/)
 
       const result = await recursiveReadDir(testpreservefileDir)
       expect(result.length).toBe(1)
     } finally {
       // Ensure test cleanup
-      await recursiveDelete(testpreservefileDir)
+      await recursiveDeleteSyncWithAsyncRetries(testpreservefileDir)
 
       const cleanupResult = await recursiveReadDir(testpreservefileDir)
       expect(cleanupResult.length).toBe(0)