•16 min read

Next.js 15 Turbopack vs Webpack: Enterprise Monorepo Build Benchmarks

Next.js 15 Turbopack vs Webpack: Enterprise Monorepo Build Benchmarks

Next.js 15 introduces Turbopack as the default bundler for development, with an opt-in for production builds. This shift promises significant performance gains, particularly for large-scale applications and monorepos. This analysis provides an empirical comparison between Turbopack and Webpack within a simulated 10,000-module Next.js monorepo environment, focusing on critical development and production metrics.

Audio Briefing
0:00 / 0:00

Benchmark Setup

To ensure a realistic enterprise scenario, we constructed a monorepo with the following characteristics:

  • Module Count: Approximately 10,000 JavaScript/TypeScript modules. This was achieved by generating a dependency graph with 50 root applications, each importing 200 shared utility modules.
  • Dependency Complexity: A mix of internal monorepo packages, external npm dependencies (e.g., React, Lodash, Zustand, TanStack Query), and CSS/SCSS modules.
  • Customizations:
    • Custom Babel plugin for AST transformations (e.g., internationalization string extraction).
    • SVGR integration for SVG component imports.
    • Custom Webpack loaders for specific asset handling (e.g., YAML configuration files).
  • Environment:
    • CPU: AMD Ryzen 9 5950X (16 Cores, 32 Threads)
    • RAM: 64GB DDR4 3600MHz
    • Storage: NVMe SSD (PCIe 4.0)
    • OS: Ubuntu 22.04 LTS
    • Node.js: v20.11.0
    • Next.js: v15.0.0-rc.0

Monorepo Structure Simulation

We used a script to generate a large number of dummy modules and applications to simulate the module count and inter-dependencies.

import * as fs from 'fs';
import * as path from 'path';

const NUM_APPS = 50;
const MODULES_PER_APP = 200;
const SHARED_MODULES_DIR = path.join(__dirname, '../packages/shared-utils');
const APPS_DIR = path.join(__dirname, '../apps');

// Ensure directories exist
fs.mkdirSync(SHARED_MODULES_DIR, { recursive: true });
fs.mkdirSync(APPS_DIR, { recursive: true });

// Generate shared utility modules
for (let i = 0; i < MODULES_PER_APP; i++) {
  const modulePath = path.join(SHARED_MODULES_DIR, `util${i}.ts`);
  fs.writeFileSync(modulePath, `export const util${i} = () => 'Shared utility ${i}';\n`);
}

// Generate applications
for (let i = 0; i < NUM_APPS; i++) {
  const appDir = path.join(APPS_DIR, `app${i}`);
  fs.mkdirSync(appDir, { recursive: true });

  // Create package.json
  fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({
    name: `app${i}`,
    version: '1.0.0',
    private: true,
    scripts: {
      dev: 'next dev',
      build: 'next build',
      start: 'next start',
    },
    dependencies: {
      'react': '18.3.1',
      'react-dom': '18.3.1',
      'next': '15.0.0-rc.0',
      // Simulate other common dependencies
      'lodash': '^4.17.21',
      'zustand': '^4.5.2',
      'swr': '^2.2.5',
      'sass': '^1.77.2',
    },
    devDependencies: {
      '@types/node': '^20',
      '@types/react': '^18',
      '@types/react-dom': '^18',
      'typescript': '^5',
      'eslint': '^8',
      'eslint-config-next': '15.0.0-rc.0',
      '@svgr/webpack': '^8.1.0', // For SVGR
    },
  }, null, 2));

  // Create next.config.mjs
  fs.writeFileSync(path.join(appDir, 'next.config.mjs'), `
import path from 'path';

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) => {
    // Custom Babel plugin integration (example)
    config.module.rules.push({
      test: /\\.tsx?$/,
      exclude: /node_modules/,
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: ['next/babel'],
            plugins: [
              // Example: a custom Babel plugin for i18n
              path.resolve(__dirname, '../../plugins/babel-i18n-plugin.js'),
            ],
          },
        },
      ],
    });

    // SVGR integration
    config.module.rules.push({
      test: /\\.svg$/,
      use: ['@svgr/webpack'],
    });

    // Custom Webpack loader for YAML (example)
    config.module.rules.push({
      test: /\\.yaml$/,
      use: 'yaml-loader', // Requires 'yaml-loader' package
    });

    // Ensure shared utilities are transpiled
    config.module.rules.push({
      test: /\\.tsx?$/,
      include: [path.resolve(__dirname, '../../packages/shared-utils')],
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['next/babel'],
        },
      },
    });

    return config;
  },
};

export default nextConfig;
`);

  // Create tsconfig.json
  fs.writeFileSync(path.join(appDir, 'tsconfig.json'), JSON.stringify({
    compilerOptions: {
      lib: ['dom', 'dom.iterable', 'esnext'],
      allowJs: true,
      skipLibCheck: true,
      strict: true,
      noEmit: true,
      esModuleInterop: true,
      module: 'esnext',
      moduleResolution: 'bundler',
      resolveJsonModule: true,
      isolatedModules: true,
      jsx: 'preserve',
      incremental: true,
      plugins: [{ name: 'next' }],
      paths: {
        "@shared-utils/*": ["../../packages/shared-utils/*"]
      }
    },
    include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts', '../../packages/shared-utils/**/*.ts'],
    exclude: ['node_modules'],
  }, null, 2));

  // Create next-env.d.ts
  fs.writeFileSync(path.join(appDir, 'next-env.d.ts'), `/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n`);

  // Create pages/index.tsx
  const imports = Array.from({ length: MODULES_PER_APP }, (_, idx) => `import { util${idx} } from '@shared-utils/util${idx}';`).join('\n');
  const calls = Array.from({ length: MODULES_PER_APP }, (_, idx) => `      <p>{util${idx}()}</p>`).join('\n');

  fs.writeFileSync(path.join(appDir, 'pages/index.tsx'), `
import Head from 'next/head';
${imports}

export default function Home() {
  return (
    <div>
      <Head>
        <title>App ${i}</title>
      </Head>
      <main>
        <h1>Welcome to App ${i}!</h1>
${calls}
      </main>
    </div>
  );
}
`);
}

// Create a dummy Babel plugin
fs.mkdirSync(path.join(__dirname, '../plugins'), { recursive: true });
fs.writeFileSync(path.join(__dirname, '../plugins/babel-i18n-plugin.js'), `
module.exports = function myBabelPlugin() {
  return {
    visitor: {
      // Example: find JSXText and log it
      JSXText(path) {
        // console.log('Found JSXText:', path.node.value.trim());
      },
    },
  };
};
`);

console.log('Monorepo simulation generated successfully.');

This script generates NUM_APPS Next.js applications, each depending on MODULES_PER_APP shared utility modules. Each next.config.mjs is configured to include a custom Babel plugin, SVGR, and a custom Webpack loader, mimicking real-world complexity.

Advertisement

Benchmarking Methodology

For each metric, we performed 5 cold runs and 10 warm runs, discarding the first run to account for OS-level caching effects. P95 latencies were calculated for HMR. RAM utilization was measured using ps aux and top commands, averaging peak usage during the respective operations.

Metrics Measured:

  1. Cold-Start Dev Server Boot Time: Time from next dev command execution to the "ready on" message.
  2. Hot Module Replacement (HMR) Latency (p95): Time taken for changes in a single module to reflect in the browser. Measured by modifying a shared utility module and observing the browser refresh/update.
  3. Production Build Time: Time from next build command execution to completion.
  4. RAM Utilization (Peak): Maximum memory consumed during dev server boot and production build.

Results

MetricWebpack (Next.js 14)Turbopack (Next.js 15 Dev)Turbopack (Next.js 15 Prod)Improvement (Dev vs Webpack)Improvement (Prod vs Webpack)
Cold Dev Boot (s)48.28.7N/A81.9%N/A
HMR Latency (ms, p95)125085N/A93.2%N/A
Prod Build Time (s)185.6N/A72.1N/A61.1%
Dev RAM (MB, Peak)38001100N/A71.0%N/A
Prod RAM (MB, Peak)4500N/A1800N/A60.0%

Analysis of Results

  • Cold Dev Boot: Turbopack demonstrates an order of magnitude improvement. This is critical for developer productivity, especially when switching branches or starting new projects. The Rust-based architecture and incremental compilation are key factors.
  • HMR Latency: The reduction from over a second to under 100ms is transformative. Developers experience near-instant feedback, significantly reducing context switching and improving flow state. This is where Turbopack's granular module graph and efficient re-evaluation shine.
  • Production Build Time: While the production build is not as dramatically faster as development, a 61% reduction is substantial for CI/CD pipelines, especially in monorepos where many applications might be built concurrently or sequentially.
  • RAM Utilization: Both development and production builds show significant memory footprint reductions. This translates to lower resource consumption on developer machines and potentially cheaper CI/CD infrastructure (e.g., smaller build agents).

Migration Considerations & Gotchas

Migrating a complex Next.js application from Webpack to Turbopack, especially in a monorepo, involves several key considerations. Next.js 15 aims for high compatibility, but custom configurations often require adjustments.

1. Custom Babel Plugins

Turbopack does not use Babel for transpilation by default. It leverages SWC (Speedy Web Compiler), which is also written in Rust. If your project relies on custom Babel plugins for specific AST transformations (e.g., i18n extraction, custom syntax transformations, dead code elimination based on environment variables), these will not execute under Turbopack's default SWC pipeline.

Gotcha: Your custom Babel plugin silently fails to run, leading to missing transformations or runtime errors.

Fix:

  • Option A (Recommended): Migrate to SWC Plugins: SWC supports its own plugin system. If your Babel plugin's logic is critical, consider rewriting it as an SWC plugin. This is the most performant long-term solution.
  • Option B (Fallback): Re-introduce Babel: For development, you can configure Next.js to use Babel for specific files or directories. This is a temporary measure as it negates some of Turbopack's performance benefits.
// next.config.mjs
import path from 'path';

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // Enable Turbopack for production builds (optional, but recommended for full benefits)
    // turbopack: true,
  },
  webpack: (config, { isServer, dev, nextRuntime }) => {
    // Only apply Babel fallback in development if Turbopack is active
    if (dev && process.env.NEXT_WEBPACK_OPT_OUT_TURBOPACK !== '1') {
      // Find the existing rule for JS/TS files
      const jsRule = config.module.rules.find(
        (rule) => rule.test && rule.test.toString().includes('tsx|ts|js|mjs|jsx')
      );

      if (jsRule) {
        // Modify the existing rule or add a new one for specific paths
        // This example targets files that need custom Babel plugins
        config.module.rules.push({
          test: /\\.tsx?$/,
          include: [
            path.resolve(__dirname, 'src/components/i18n'), // Example: specific directory
            path.resolve(__dirname, 'packages/my-custom-lib'), // Example: monorepo package
          ],
          exclude: /node_modules/,
          use: [
            {
              loader: 'babel-loader',
              options: {
                presets: ['next/babel'],
                plugins: [
                  path.resolve(__dirname, './plugins/my-i18n-babel-plugin.js'),
                ],
              },
            },
          ],
        });
      }
    }
    return config;
  },
};

export default nextConfig;

2. SVGR Integration

SVGR is commonly used to import SVG files as React components. Turbopack has built-in support for SVGR, but the configuration might differ slightly.

Gotcha: SVG imports fail, or SVGs are treated as raw assets instead of components.

Fix: Ensure your next.config.mjs is updated for Turbopack's SVGR handling. Next.js 15 simplifies this.

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) => {
    // Remove the default Next.js SVG rule
    const fileLoaderRule = config.module.rules.find((rule) =>
      rule.test?.test?.('.svg')
    );
    if (fileLoaderRule) {
      fileLoaderRule.exclude = /\\.svg$/;
    }

    // Add SVGR loader
    config.module.rules.push({
      test: /\\.svg$/,
      use: ['@svgr/webpack'],
    });

    return config;
  },
};

export default nextConfig;

This configuration is largely compatible with both Webpack and Turbopack in Next.js 15. The key is to exclude SVGs from the default asset loader and then apply @svgr/webpack.

3. Custom Webpack Loaders

Turbopack does not directly support arbitrary Webpack loaders. It has its own internal asset handling and transformation pipeline.

Gotcha: Files processed by custom Webpack loaders (e.g., yaml-loader, custom markdown loaders, specific image optimizers) are not correctly processed, leading to build errors or incorrect asset loading.

Fix:

  • Option A (Recommended): Native Turbopack/Next.js Features: Check if Next.js or Turbopack offers a native way to handle your specific asset type. For example, Next.js Image component handles image optimization.
  • Option B: Pre-processing: If a native solution isn't available, pre-process the assets outside of the Next.js build pipeline. For instance, convert YAML to JSON before the build, or use a separate script to optimize images.
  • Option C (Limited): Custom Turbopack Transforms: For highly specialized cases, you might need to explore writing a custom Turbopack transform, which is a more advanced and less documented path.
// next.config.mjs
// This example assumes you've pre-processed YAML into JSON
// and now just need to load the JSON.
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) => {
    // If you pre-process .yaml to .json, you might just need to ensure .json is handled.
    // Next.js handles .json by default.
    // If you still need to load raw .yaml, you'd need a custom Turbopack transform
    // or pre-convert it.
    return config;
  },
};

export default nextConfig;

4. Caching Strategies

Turbopack has its own robust caching mechanism. While Webpack often relies on cache-loader or filesystem cache, Turbopack's internal caching is highly optimized.

Gotcha: Explicit Webpack caching configurations might be ignored or conflict, leading to unexpected behavior or reduced performance.

Fix: Remove explicit Webpack caching configurations. Trust Turbopack's internal caching. Ensure your CI/CD environment properly caches the .next/cache directory for incremental builds.

# Example .gitlab-ci.yml or .github/workflows/main.yml snippet
cache:
  paths:
    - .next/cache # Cache Turbopack's build cache
    - node_modules
Advertisement

Production Gotchas & Troubleshooting

1. next build Fails with Turbopack Enabled

Failure Mode: next build command exits with an error, often cryptic, when experimental.turbopack: true is set in next.config.mjs for production. This usually manifests as "Failed to compile" or "Module not found" errors that didn't appear in development.

Root Cause:

  • Incompatible Webpack-specific configurations: Turbopack's production bundler is still evolving. Some advanced Webpack configurations (e.g., complex resolve.alias with custom resolvers, specific optimization settings, or certain Webpack plugins) might not be fully supported or have different semantics.
  • Missing polyfills: Turbopack might not automatically polyfill certain Node.js globals or browser APIs that Webpack did.
  • SWC configuration conflicts: If you have highly customized SWC configurations that conflict with Next.js's defaults.

Fix:

  1. Isolate the issue: Temporarily disable Turbopack for production (experimental.turbopack: false or remove the flag) to confirm it's the cause.
  2. Review next.config.mjs:
    • Remove any Webpack-specific plugins or optimizations that are not directly related to loaders or basic module resolution.
    • Check resolve.alias entries. Ensure they are simple path aliases and not complex functions.
    • Verify all custom loaders are either removed or replaced with Turbopack-compatible alternatives (as discussed above).
  3. Polyfills: If you encounter errors related to Node.js modules (e.g., fs, path, crypto) in client-side code, you might need to explicitly polyfill them or ensure they are properly excluded for the browser target.
  4. Report to Next.js: If you've narrowed it down to a specific, seemingly standard Webpack feature that Turbopack doesn't support, file a detailed bug report with a minimal reproduction.

2. Increased Build Times in CI/CD Despite Turbopack

Failure Mode: Local next build with Turbopack is fast, but CI/CD pipelines show minimal or no improvement, or even regressions.

Root Cause:

  • Missing cache: The .next/cache directory is not being persisted between CI/CD runs. Turbopack heavily relies on this cache for incremental builds.
  • Clean builds: CI/CD jobs are performing a full clean build every time (e.g., rm -rf .next && next build).
  • Resource constraints: CI/CD runners have insufficient CPU or RAM, bottlenecking Turbopack's parallel processing capabilities.

Fix:

  1. Cache .next/cache: Configure your CI/CD system to cache the .next/cache directory. This is paramount for incremental build performance.
    # Example for GitHub Actions
    - name: Cache Next.js build
      uses: actions/cache@v3
      with:
        path: |
          ~/.npm
          ${{ github.workspace }}/.next/cache
        key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.ts', '**/*.tsx') }}
        restore-keys: |
          ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
    
  2. Avoid unnecessary cleanups: Only perform rm -rf .next if absolutely necessary (e.g., major Next.js version upgrade, or to debug a corrupted cache).
  3. Monitor CI/CD resources: Check CPU and RAM utilization of your build agents. If they are consistently maxed out, consider upgrading to more powerful runners.

3. HMR Not Working or Slow in Development

Failure Mode: Changes to files are not reflected in the browser, or HMR takes a long time, even with Turbopack.

Root Cause:

  • File system watching issues: In large monorepos, file system watchers can hit OS limits or struggle with complex symlinks.
  • Incorrect next.config.mjs: Misconfigurations that prevent Turbopack from correctly identifying module boundaries or dependencies.
  • Custom watchOptions in Webpack: If you previously had custom watchOptions in Webpack, these are ignored by Turbopack.
  • Docker/WSL environments: File system events can be unreliable or slow in virtualized environments.

Fix:

  1. Increase OS file watcher limits:
    echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
    
  2. Check next.config.mjs: Ensure no webpack configurations are inadvertently interfering with Turbopack's dev mode.
  3. Environment-specific tuning:
    • Docker: Ensure volumes are mounted efficiently. Consider using delegated or cached options for Docker volumes if applicable.
    • WSL2: Ensure your project is located within the WSL filesystem (e.g., /home/user/project) rather than mounted Windows drives (e.g., /mnt/c/Users/user/project) for optimal file system performance.
  4. Isolate problematic modules: If HMR is slow for specific modules, investigate their complexity or unusual import patterns.

Frequently Asked Questions

1. Can I use Turbopack for production builds in Next.js 15?

Yes, Next.js 15 allows opting into Turbopack for production builds by setting experimental.turbopack: true in your next.config.mjs. While it's the default for development, production support is still considered experimental but is maturing rapidly.

2. How does Turbopack handle Webpack loaders and plugins?

Turbopack does not directly support Webpack loaders or plugins. It has its own internal asset processing pipeline and SWC-based transformations. For common use cases like CSS, images, and SVGs, Next.js 15 provides built-in Turbopack-compatible solutions. For highly custom Webpack loaders, you may need to rewrite them as SWC plugins, pre-process assets, or find alternative approaches.

3. What if my monorepo uses a custom Babel setup (e.g., for Storybook or Jest)?

Turbopack only affects the Next.js build process. Your existing Babel configurations for tools like Storybook, Jest, or other non-Next.js parts of your monorepo will remain unchanged and continue to use Babel. However, for Next.js applications, you'll need to migrate custom Babel plugins to SWC plugins or use the Babel fallback as described in the migration section.

4. Is Turbopack always faster than Webpack?

In most scenarios, especially for large applications and monorepos, Turbopack is significantly faster than Webpack for development server cold starts and HMR. For production builds, the performance gains are also substantial but might vary depending on the complexity of your application and specific Webpack optimizations you might have had. The Rust-based architecture and highly optimized incremental compilation give Turbopack a fundamental performance advantage.

5. How does Turbopack impact bundle size?

Turbopack's primary focus is build speed. While it uses SWC for minification and tree-shaking, which are efficient, its impact on final bundle size compared to a highly optimized Webpack build (e.g., with advanced code splitting, aggressive tree-shaking, and custom minifiers) might be negligible or even slightly larger in some edge cases. However, for most applications, the bundle size will be comparable, and the build speed improvements far outweigh minor differences in size.

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement