Skip to content

Commit 31c047d

Browse files
authored
feat: slow module detection for webpack (#75368)
## Problem During development, certain modules can take an unexpectedly long time to compile, significantly impacting build performance. Currently, developers have no visibility into which modules are causing these slowdowns, making it difficult to optimize their codebase. ## Solution This PR introduces a new experimental feature that detects and reports modules that are slow to compile during webpack builds. When enabled, it: - Tracks build time for each module - Reports modules that exceed a configurable time threshold - Shows a dependency tree visualization of slow modules - Helps developers identify performance bottlenecks in their build process ### Configuration Enable in `next.config.js`: ```js module.exports = { experimental: { slowModuleDetection: { buildTimeThresholdMs: 500, // Time threshold to consider a module "slow" } } } ``` ### Example Output ![image](https://github.com/user-attachments/assets/c01f03fa-5b09-4ad1-af2e-255ed029c568) ## Implementation Details - Implements a new webpack plugin (`SlowModuleDetectionPlugin`) that hooks into the compilation process - Uses webpack's module graph to build a dependency tree of slow modules - Provides clear, actionable output to help developers optimize their builds - Adds comprehensive error handling with detailed error messages - Includes e2e tests to ensure reliability ## Limitations - **To ensure accurate measurements, we will set the [parallelism](https://webpack.js.org/configuration/other-options/#parallelism) option to 1 in the Webpack configuration (see [Webpack documentation](https://webpack.js.org/configuration/other-options/#parallelism)) when the feature is enabled. This may significantly slow down development performance, but it will help surface issues proportionally to their normal duration.** - Currently only supports webpack builds. Turbopack support will be added in a future PR - Only tracks module build time, not other compilation steps - Thresholds may need tuning based on project size and complexity https://linear.app/vercel/issue/NDX-86/slow-module-detection
1 parent 274ce80 commit 31c047d

File tree

10 files changed

+348
-2
lines changed

10 files changed

+348
-2
lines changed

packages/next/errors.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -626,5 +626,9 @@
626626
"625": "Server Actions are not supported with static export.\\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features",
627627
"626": "Intercepting routes are not supported with static export.\\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features",
628628
"627": "cacheLife() is only available with the experimental.useCache config.",
629-
"628": "cacheTag() is only available with the experimental.useCache config."
629+
"628": "cacheTag() is only available with the experimental.useCache config.",
630+
"629": "Invariant (SlowModuleDetectionPlugin): Unable to find the start time for a module build. This is a Next.js internal bug.",
631+
"630": "Invariant (SlowModuleDetectionPlugin): Module is recorded after the report is generated. This is a Next.js internal bug.",
632+
"631": "Invariant (SlowModuleDetectionPlugin): Circular dependency detected in module graph. This is a Next.js internal bug.",
633+
"632": "Invariant (SlowModuleDetectionPlugin): Module is missing a required debugId. This is a Next.js internal bug."
630634
}

packages/next/src/build/webpack-config.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -811,8 +811,24 @@ export default async function getBaseWebpackConfig(
811811

812812
const builtinModules = require('module').builtinModules
813813

814+
const shouldEnableSlowModuleDetection =
815+
!!config.experimental.slowModuleDetection && dev
816+
817+
const getParallelism = () => {
818+
const override = Number(process.env.NEXT_WEBPACK_PARALLELISM)
819+
if (shouldEnableSlowModuleDetection) {
820+
if (override) {
821+
console.warn(
822+
'NEXT_WEBPACK_PARALLELISM is specified but will be ignored due to experimental.slowModuleDetection being enabled.'
823+
)
824+
}
825+
return 1
826+
}
827+
return override || undefined
828+
}
829+
814830
let webpackConfig: webpack.Configuration = {
815-
parallelism: Number(process.env.NEXT_WEBPACK_PARALLELISM) || undefined,
831+
parallelism: getParallelism(),
816832
...(isNodeServer ? { externalsPresets: { node: true } } : {}),
817833
// @ts-ignore
818834
externals:
@@ -1944,6 +1960,13 @@ export default async function getBaseWebpackConfig(
19441960
new (
19451961
require('./webpack/plugins/telemetry-plugin/telemetry-plugin') as typeof import('./webpack/plugins/telemetry-plugin/telemetry-plugin')
19461962
).TelemetryPlugin(new Map()),
1963+
shouldEnableSlowModuleDetection &&
1964+
new (
1965+
require('./webpack/plugins/slow-module-detection-plugin') as typeof import('./webpack/plugins/slow-module-detection-plugin')
1966+
).default({
1967+
compilerType,
1968+
...config.experimental.slowModuleDetection!,
1969+
}),
19471970
].filter(Boolean as any as ExcludesFalse),
19481971
}
19491972

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import type { Compiler, Module, Compilation } from 'webpack'
2+
import type { CompilerNameValues } from '../../../shared/lib/constants'
3+
import { yellow, green, blue } from '../../../lib/picocolors'
4+
5+
const PLUGIN_NAME = 'SlowModuleDetectionPlugin'
6+
7+
const TreeSymbols = {
8+
VERTICAL_LINE: '│ ',
9+
BRANCH: '├─ ',
10+
} as const
11+
12+
const PATH_TRUNCATION_LENGTH = 120
13+
14+
// Matches node_modules paths, including pnpm-style paths
15+
const NODE_MODULES_PATH_PATTERN = /node_modules(?:\/\.pnpm)?\/(.*)/
16+
17+
interface ModuleBuildTimeAnalyzerOptions {
18+
compilerType: CompilerNameValues
19+
buildTimeThresholdMs: number
20+
}
21+
22+
const getModuleIdentifier = (module: Module): string => {
23+
const debugId = module.debugId
24+
return String(debugId)
25+
}
26+
27+
const getModuleDisplayName = (module: Module): string | undefined => {
28+
const resourcePath =
29+
'resource' in module && typeof module.resource === 'string'
30+
? module.resource
31+
: undefined
32+
33+
if (!resourcePath) {
34+
return undefined
35+
}
36+
37+
let displayPath = resourcePath.replace(process.cwd(), '.')
38+
39+
const nodeModulesMatch = displayPath.match(NODE_MODULES_PATH_PATTERN)
40+
if (nodeModulesMatch) {
41+
return nodeModulesMatch[1]
42+
}
43+
44+
return displayPath
45+
}
46+
47+
/**
48+
* Truncates a path to a maximum length. If the path exceeds this length,
49+
* it will be truncated in the middle and replaced with '...'.
50+
*/
51+
function truncatePath(path: string, maxLength: number): string {
52+
// If the path length is within the limit, return it as is
53+
if (path.length <= maxLength) return path
54+
55+
// Calculate the available length for the start and end segments after accounting for '...'
56+
const availableLength = maxLength - 3
57+
const startSegmentLength = Math.ceil(availableLength / 2)
58+
const endSegmentLength = Math.floor(availableLength / 2)
59+
60+
// Extract the start and end segments of the path
61+
const startSegment = path.slice(0, startSegmentLength)
62+
const endSegment = path.slice(-endSegmentLength)
63+
64+
// Return the truncated path with '...' in the middle
65+
return `${startSegment}...${endSegment}`
66+
}
67+
68+
class ModuleBuildTimeAnalyzer {
69+
private pendingModules: Module[] = []
70+
private modules = new Map<string, Module>()
71+
private moduleParents = new Map<Module, Module>()
72+
private moduleChildren = new Map<Module, Map<string, Module>>()
73+
private isFinalized = false
74+
private buildTimeThresholdMs: number
75+
private moduleBuildTimes = new WeakMap<Module, number>()
76+
77+
constructor(private options: ModuleBuildTimeAnalyzerOptions) {
78+
this.buildTimeThresholdMs = options.buildTimeThresholdMs
79+
}
80+
81+
recordModuleBuildTime(module: Module, duration: number) {
82+
// Webpack guarantees that no more modules will be built after finishModules hook is called,
83+
// where we generate the report. This check is just a defensive measure.
84+
if (this.isFinalized) {
85+
throw new Error(
86+
`Invariant (SlowModuleDetectionPlugin): Module is recorded after the report is generated. This is a Next.js internal bug.`
87+
)
88+
}
89+
90+
if (duration < this.buildTimeThresholdMs) {
91+
return // Skip fast modules
92+
}
93+
94+
this.moduleBuildTimes.set(module, duration)
95+
this.pendingModules.push(module)
96+
}
97+
98+
/**
99+
* For each slow module, traverses up the dependency chain to find all ancestor modules.
100+
* Builds a directed graph where:
101+
* 1. Each slow module and its ancestors become nodes
102+
* 2. Edges represent "imported by" relationships
103+
* 3. Root nodes are entry points with no parents
104+
*
105+
* The resulting graph allows us to visualize the import chains that led to slow builds.
106+
*/
107+
private prepareReport(compilation: Compilation) {
108+
for (const module of this.pendingModules) {
109+
const chain = new Set<Module>()
110+
111+
// Walk up the module graph until we hit a root module (no issuer) to populate the chain
112+
{
113+
let currentModule = module
114+
chain.add(currentModule)
115+
while (true) {
116+
const issuerModule = compilation.moduleGraph.getIssuer(currentModule)
117+
if (!issuerModule) break
118+
if (chain.has(issuerModule)) {
119+
throw new Error(
120+
`Invariant (SlowModuleDetectionPlugin): Circular dependency detected in module graph. This is a Next.js internal bug.`
121+
)
122+
}
123+
chain.add(issuerModule)
124+
currentModule = issuerModule
125+
}
126+
}
127+
128+
// Add all visited modules to our graph and create parent-child relationships
129+
let previousModule: Module | null = null
130+
for (const currentModule of chain) {
131+
const moduleId = getModuleIdentifier(currentModule)
132+
if (!this.modules.has(moduleId)) {
133+
this.modules.set(moduleId, currentModule)
134+
}
135+
136+
if (previousModule) {
137+
this.moduleParents.set(previousModule, currentModule)
138+
139+
let parentChildren = this.moduleChildren.get(currentModule)
140+
if (!parentChildren) {
141+
parentChildren = new Map()
142+
this.moduleChildren.set(currentModule, parentChildren)
143+
}
144+
parentChildren.set(
145+
getModuleIdentifier(previousModule),
146+
previousModule
147+
)
148+
}
149+
150+
previousModule = currentModule
151+
}
152+
}
153+
this.isFinalized = true
154+
}
155+
156+
generateReport(compilation: Compilation) {
157+
if (!this.isFinalized) {
158+
this.prepareReport(compilation)
159+
}
160+
161+
// Find root modules (those with no parents)
162+
const rootModules = [...this.modules.values()].filter(
163+
(node) => !this.moduleParents.has(node)
164+
)
165+
166+
const formatModuleNode = (node: Module, depth: number): string => {
167+
const moduleName = getModuleDisplayName(node) || ''
168+
169+
if (!moduleName) {
170+
return formatChildModules(node, depth)
171+
}
172+
173+
const prefix =
174+
' ' + TreeSymbols.VERTICAL_LINE.repeat(depth) + TreeSymbols.BRANCH
175+
176+
const moduleText = blue(
177+
truncatePath(moduleName, PATH_TRUNCATION_LENGTH - prefix.length)
178+
)
179+
180+
const buildTimeMs = this.moduleBuildTimes.get(node)
181+
const duration = buildTimeMs
182+
? yellow(` (${Math.ceil(buildTimeMs)}ms)`)
183+
: ''
184+
185+
return (
186+
prefix +
187+
moduleText +
188+
duration +
189+
'\n' +
190+
formatChildModules(node, depth + 1)
191+
)
192+
}
193+
194+
const formatChildModules = (node: Module, depth: number): string => {
195+
const children = this.moduleChildren.get(node)
196+
if (!children) return ''
197+
198+
return [...children]
199+
.map(([_, child]) => formatModuleNode(child, depth))
200+
.join('')
201+
}
202+
203+
const report = rootModules.map((root) => formatModuleNode(root, 0)).join('')
204+
205+
if (report) {
206+
console.log(
207+
green(
208+
`🐌 Detected slow modules while compiling ${this.options.compilerType}:`
209+
) +
210+
'\n' +
211+
report
212+
)
213+
}
214+
}
215+
}
216+
217+
export default class SlowModuleDetectionPlugin {
218+
constructor(private options: ModuleBuildTimeAnalyzerOptions) {}
219+
220+
apply = (compiler: Compiler) => {
221+
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
222+
const analyzer = new ModuleBuildTimeAnalyzer(this.options)
223+
const moduleBuildStartTimes = new WeakMap<Module, number>()
224+
225+
compilation.hooks.buildModule.tap(PLUGIN_NAME, (module) => {
226+
moduleBuildStartTimes.set(module, performance.now())
227+
})
228+
229+
compilation.hooks.succeedModule.tap(PLUGIN_NAME, (module) => {
230+
const startTime = moduleBuildStartTimes.get(module)
231+
if (!startTime) {
232+
throw new Error(
233+
`Invariant (SlowModuleDetectionPlugin): Unable to find the start time for a module build. This is a Next.js internal bug.`
234+
)
235+
}
236+
analyzer.recordModuleBuildTime(module, performance.now() - startTime)
237+
})
238+
239+
compilation.hooks.finishModules.tap(PLUGIN_NAME, () => {
240+
analyzer.generateReport(compilation)
241+
})
242+
})
243+
}
244+
}

packages/next/src/lib/turbopack-warning.ts

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const unsupportedTurbopackNextConfigOptions = [
4545
'experimental.forceSwcTransforms',
4646
'experimental.fullySpecified',
4747
'experimental.urlImports',
48+
'experimental.slowModuleDetection',
4849
]
4950

5051
// The following will need to be supported by `next build --turbopack`

packages/next/src/server/config-schema.ts

+5
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
444444
streamingMetadata: z.boolean().optional(),
445445
htmlLimitedBots: z.instanceof(RegExp).optional(),
446446
useCache: z.boolean().optional(),
447+
slowModuleDetection: z
448+
.object({
449+
buildTimeThresholdMs: z.number().int(),
450+
})
451+
.optional(),
447452
})
448453
.optional(),
449454
exportPathMap: z

packages/next/src/server/config-shared.ts

+13
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,18 @@ export interface ExperimentalConfig {
612612
* Enables the use of the `"use cache"` directive.
613613
*/
614614
useCache?: boolean
615+
616+
/**
617+
* Enables detection and reporting of slow modules during development builds.
618+
* Enabling this may impact build performance to ensure accurate measurements.
619+
*/
620+
slowModuleDetection?: {
621+
/**
622+
* The time threshold in milliseconds for identifying slow modules.
623+
* Modules taking longer than this build time threshold will be reported.
624+
*/
625+
buildTimeThresholdMs: number
626+
}
615627
}
616628

617629
export type ExportPathMap = {
@@ -1243,6 +1255,7 @@ export const defaultConfig: NextConfig = {
12431255
streamingMetadata: false,
12441256
htmlLimitedBots: undefined,
12451257
useCache: undefined,
1258+
slowModuleDetection: undefined,
12461259
},
12471260
bundlePagesRouterDependencies: false,
12481261
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pages/slowModule.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { fetchViaHTTP } from 'next-test-utils'
3+
;(process.env.TURBOPACK ? describe.skip : describe)(
4+
'Slow Module Detection',
5+
() => {
6+
const { next } = nextTestSetup({
7+
files: __dirname,
8+
overrideFiles: {
9+
'utils/slow-module.js': `
10+
// This module is intentionally made complex to trigger slow compilation
11+
${Array(2000)
12+
.fill(0)
13+
.map(
14+
(_, i) => `
15+
export const value${i} = ${JSON.stringify(
16+
Array(100).fill(`some-long-string-${i}`).join('')
17+
)}
18+
`
19+
)
20+
.join('\n')}
21+
`,
22+
},
23+
})
24+
25+
it('should detect slow modules in webpack mode', async () => {
26+
let logs = ''
27+
next.on('stdout', (log) => {
28+
logs += log
29+
})
30+
31+
// Trigger a compilation by making a request
32+
await fetchViaHTTP(next.url, '/')
33+
34+
// Wait for compilation to complete and check logs
35+
await new Promise((resolve) => setTimeout(resolve, 1000))
36+
37+
// Verify slow module detection output
38+
expect(logs).toContain('🐌 Detected slow modules while compiling client:')
39+
expect(logs).toContain('./utils/slow-module.js')
40+
})
41+
}
42+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
experimental: {
3+
slowModuleDetection: {
4+
buildTimeThresholdMs: 50,
5+
},
6+
},
7+
}

0 commit comments

Comments
 (0)