Repository: vercel/next.js
Pull request: Remove loadConfig from main development process, pass value from child process

## Eliminate duplicate `loadConfig` calls during development server startup

### What?

Remove duplicate `loadConfig` calls from the parent process (`next-dev.ts`) by passing `distDir` via IPC from the child process where config is already loaded.

### Why?

Previously, when booting the development server:

1. The **child process** (`start-server.ts` → `router-server.ts`) would call `loadConfig` to get the full configuration for the actual server
2. The **parent process** (`next-dev.ts`) would also call `loadConfig` separately just to access `config.distDir` for telemetry and trace uploads

Since these are separate processes, the in-memory config cache couldn't be shared, resulting in redundant config loading work.

### How?

Extended the existing IPC messaging to include `distDir` when the child process sends `nextServerReady`:

```
Parent (next-dev.ts)         Child (start-server.ts → router-server.ts)
        |                                    |
        |-------- fork() ------------------>|
        |<------- nextWorkerReady ----------|
        |-------- nextWorkerOptions ------->|
        |                                   | loadConfig() ← only called here now
        |<-- nextServerReady + distDir -----|
        |                                    |
   Store distDir for                         
   telemetry/trace uploads                   
```

**Changes:**

- **`render-server.ts`**: Added `distDir` to `ServerInitResult` type
- **`router-server.ts`**: Return `config.distDir` in `initialize()` result and pass to render server opts
- **`start-server.ts`**: Return `distDir` from `startServer()` and include it in IPC message
- **`next-dev.ts`**: Receive `distDir` from IPC, remove both `loadConfig` calls, use `distDir` directly for telemetry/trace

Changed files:
filename: packages/next/src/cli/next-dev.ts
patch: @@ -16,12 +16,9 @@ import {
 } from '../server/lib/utils'
 import * as Log from '../build/output/log'
 import { getProjectDir } from '../lib/get-project-dir'
-import { PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants'
 import path from 'path'
-import type { NextConfigComplete } from '../server/config-shared'
 import { traceGlobals } from '../trace/shared'
 import { Telemetry } from '../telemetry/storage'
-import loadConfig from '../server/config'
 import { findPagesDir } from '../lib/find-pages-dir'
 import { fileExists, FileType } from '../lib/file-exists'
 import { getNpxCommand } from '../lib/helpers/get-npx-command'
@@ -40,11 +37,7 @@ import { once } from 'node:events'
 import { clearTimeout } from 'timers'
 import { flushAllTraces, trace } from '../trace'
 import { traceId } from '../trace/shared'
-import {
-  Bundler,
-  finalizeBundlerFromConfig,
-  parseBundlerArgs,
-} from '../lib/bundler'
+import { Bundler, parseBundlerArgs } from '../lib/bundler'
 
 export type NextDevOptions = {
   disableSourceMaps: boolean
@@ -68,9 +61,9 @@ type PortSource = 'cli' | 'default' | 'env'
 
 let dir: string
 let child: undefined | ChildProcess
-// The config in next-dev is only used to access config.distDir for telemetry and trace.
-let config: NextConfigComplete
-let bundler: Bundler
+// distDir is received from the child process via IPC, used for telemetry and trace.
+let distDir: string | undefined
+let isTurbopack: boolean
 let traceUploadUrl: string
 let sessionStopHandled = false
 const sessionStarted = Date.now()
@@ -124,24 +117,18 @@ const handleSessionStop = async (signal: NodeJS.Signals | number | null) => {
       pagesDir = !!pagesResult.pagesDir
     }
 
-    config =
-      config ||
-      (await loadConfig(PHASE_DEVELOPMENT_SERVER, dir, { silent: true }))
-
     let telemetry =
       (traceGlobals.get('telemetry') as InstanceType<
         typeof import('../telemetry/storage').Telemetry
       >) ||
       new Telemetry({
-        distDir: path.join(dir, config.distDir),
+        distDir: path.join(dir, distDir || '.next'),
       })
-    // Reading the config can modify environment variables that influence the bundler selection.
-    bundler = finalizeBundlerFromConfig(bundler)
 
     telemetry.record(
       eventCliSessionStopped({
         cliCommand: 'dev',
-        turboFlag: bundler === Bundler.Turbopack,
+        turboFlag: isTurbopack,
         durationMilliseconds: Date.now() - sessionStarted,
         pagesDir,
         appDir,
@@ -154,13 +141,13 @@ const handleSessionStop = async (signal: NodeJS.Signals | number | null) => {
     // noise to the output
   }
 
-  if (traceUploadUrl) {
+  if (traceUploadUrl && distDir) {
     uploadTrace({
       traceUploadUrl,
       mode: 'dev',
       projectDir: dir,
-      distDir: config.distDir,
-      isTurboSession: bundler === Bundler.Turbopack,
+      distDir,
+      isTurboSession: isTurbopack,
     })
   }
 
@@ -185,7 +172,9 @@ const nextDev = async (
   portSource: PortSource,
   directory?: string
 ) => {
-  bundler = parseBundlerArgs(options)
+  // Note: parseBundlerArgs can only decide on Turbopack or webpack.
+  // Rspack can be configured via next.config.js but next.config.js is not loaded in the main process, only in the child process.
+  isTurbopack = parseBundlerArgs(options) === Bundler.Turbopack
 
   dir = getProjectDir(process.env.NEXT_PRIVATE_DEV_DIR || directory)
 
@@ -315,9 +304,7 @@ const nextDev = async (
         stdio: 'inherit',
         env: {
           ...defaultEnv,
-          ...(bundler === Bundler.Turbopack
-            ? { TURBOPACK: process.env.TURBOPACK }
-            : undefined),
+          ...(isTurbopack ? { TURBOPACK: process.env.TURBOPACK } : undefined),
           NEXT_PRIVATE_WORKER: '1',
           NEXT_PRIVATE_TRACE_ID: traceId,
           NODE_EXTRA_CA_CERTS: startServerOptions.selfSignedCertificate
@@ -350,6 +337,10 @@ const nextDev = async (
               // it can be re-used on automatic dev server restarts.
               port = parseInt(msg.port, 10)
             }
+            if (msg.distDir) {
+              // Store the distDir from the child process for telemetry and trace uploads.
+              distDir = msg.distDir
+            }
 
             resolved = true
             resolve()
@@ -365,21 +356,13 @@ const nextDev = async (
           // Starting the dev server will overwrite the `.next/trace` file, so we
           // must upload the existing contents before restarting the server to
           // preserve the metrics.
-          if (traceUploadUrl) {
-            // Postpone loading next config when we need to get
-            //  config.distDir for upload trace.
-            config =
-              config ||
-              (await loadConfig(PHASE_DEVELOPMENT_SERVER, dir, {
-                silent: true,
-              }))
-            bundler = finalizeBundlerFromConfig(bundler)
+          if (traceUploadUrl && distDir) {
             uploadTrace({
               traceUploadUrl,
               mode: 'dev',
               projectDir: dir,
-              distDir: config.distDir,
-              isTurboSession: bundler === Bundler.Turbopack,
+              distDir,
+              isTurboSession: isTurbopack,
               sync: true,
             })
           }

filename: packages/next/src/server/lib/render-server.ts
patch: @@ -15,6 +15,8 @@ export type ServerInitResult = {
   server: NextServer
   // Make an effort to close upgraded HTTP requests (e.g. Turbopack HMR websockets)
   closeUpgraded: () => void
+  // The distDir from config, used by the parent process for telemetry/trace
+  distDir: string
 }
 
 let initializations: Record<string, Promise<ServerInitResult> | undefined> = {}
@@ -91,6 +93,7 @@ async function initializeImpl(opts: {
   startServerSpan: Span | undefined
   quiet?: boolean
   onDevServerCleanup: ((listener: () => Promise<void>) => void) | undefined
+  distDir: string
 }): Promise<ServerInitResult> {
   const type = process.env.__NEXT_PRIVATE_RENDER_WORKER
   if (type) {
@@ -166,6 +169,7 @@ async function initializeImpl(opts: {
     closeUpgraded() {
       opts.bundlerService?.close()
     },
+    distDir: opts.distDir,
   }
 }
 

filename: packages/next/src/server/lib/router-server.ts
patch: @@ -727,6 +727,7 @@ export async function initialize(opts: {
     startServerSpan: opts.startServerSpan,
     quiet: opts.quiet,
     onDevServerCleanup: opts.onDevServerCleanup,
+    distDir: config.distDir,
   }
   renderServerOpts.serverFields.routerServerHandler = requestHandlerImpl
 
@@ -898,5 +899,6 @@ export async function initialize(opts: {
     closeUpgraded() {
       development?.bundler?.hotReloader?.close()
     },
+    distDir: config.distDir,
   }
 }

filename: packages/next/src/server/lib/start-server.ts
patch: @@ -166,9 +166,13 @@ export async function getRequestHandlers({
   })
 }
 
+export type StartServerResult = {
+  distDir: string
+}
+
 export async function startServer(
   serverOptions: StartServerOptions
-): Promise<void> {
+): Promise<StartServerResult> {
   const {
     dir,
     isDev,
@@ -301,7 +305,7 @@ export async function startServer(
 
   let cleanupListeners = isDev ? new AsyncCallbackSet() : undefined
 
-  await new Promise<void>((resolve) => {
+  const distDir = await new Promise<string>((resolve) => {
     server.on('listening', async () => {
       const addr = server.address()
       const actualHostname = formatHostname(
@@ -482,14 +486,14 @@ export async function startServer(
             configPhase: PHASE_DEVELOPMENT_SERVER,
           })
         }
+
+        resolve(initResult.distDir)
       } catch (err) {
         // fatal error if we can't setup
         handlersError()
         console.error(err)
         process.exit(1)
       }
-
-      resolve()
     })
     server.listen(port, hostname)
   })
@@ -521,6 +525,8 @@ export async function startServer(
       process.exit(RESTART_EXIT_CODE)
     })
   }
+
+  return { distDir }
 }
 
 if (process.env.NEXT_PRIVATE_WORKER && process.send) {
@@ -538,7 +544,7 @@ if (process.env.NEXT_PRIVATE_WORKER && process.send) {
         'memory.totalMem': String(os.totalmem()),
         'memory.heapSizeLimit': String(v8.getHeapStatistics().heap_size_limit),
       })
-      await startServerSpan.traceAsyncFn(() =>
+      const result = await startServerSpan.traceAsyncFn(() =>
         startServer(msg.nextWorkerOptions)
       )
       const memoryUsage = process.memoryUsage()
@@ -551,7 +557,11 @@ if (process.env.NEXT_PRIVATE_WORKER && process.send) {
         'memory.heapUsed',
         String(memoryUsage.heapUsed)
       )
-      process.send({ nextServerReady: true, port: process.env.PORT })
+      process.send({
+        nextServerReady: true,
+        port: process.env.PORT,
+        distDir: result.distDir,
+      })
     }
   })
   process.send({ nextWorkerReady: true })