DEV Community

Alexey Yakovlev
Alexey Yakovlev

Posted on

Webpack On-Demand Compilation: How to Speed up Dev mode of Legacy Webpack Applications

Disclaimer: Due to the extensible and configurable nature of Webpack this article will likely not supply you with a ready-made solution to introduce on-demand compilation to Webpack. But it will present a few examples and give a general idea of how your implementation could look like.

If you are not interested in the supplementary introduction, you can skip right to the implementation part.

Introduction

According to the State of JS Survey, Webpack remains the most used Build Tool in the JavaScript Ecosystem as of the end of 2022. However, comments from respondents suggest that while Webpack is crucial to their build pipelines, its performance and general developer experience are among the most disliked aspects of using it. Some users also express frustration with the lack of relevant examples available online - which I am trying to fix right now. Still, for many, switching to a different build tool is not an option.

Build Tools Rankings - Webpack was used by 85% of respondents

I have personally experienced this situation myself. In the project I work on, we have a large monolithic application consisting of 12,700 modules, all compiled using a single Webpack configuration. Unfortunately, the application, especially crucial parts like Server Side Rendering, is tightly coupled to the way Webpack works, making a complete rewrite too expensive at the moment.

Despite these challenges, the application serves a large business that needs to continue growing. However, local development of new features is slow and cumbersome, with most developers taking a coffee break while waiting for the application to boot up. Hot reload is also slow, to the point I would no longer consider it "hot". While we have implemented newer transpilers and minifiers like ESBuild and SWC to replace the outdated Babel and Terser, the performance improvements have not been significant enough to eliminate these issues.

At some point, I couldn't tolerate this any longer and started to explore other ways to improve performance. Given that our application consists of multiple entrypoints or "bundles," it made sense to attempt to compile only the necessary entrypoints when demanded by the user (developer). This concept is known as on-demand compilation. However, a search for "Webpack on-demand compilation" on the web returned very few relevant results.

But amidst the list of largely irrelevant results, I stumbled upon a hidden gem - a Merge Request to the internal GitLab Repo introducing the so-called "incremental compiler." Don't let the name deceive you - it is actually about on-demand compilation. Incremental compilation is a different technique that allows the compiler to rebuild only the code that has changed. Webpack already incorporates this technique, as every hot reload does not take the same amount of time as a complete build. We can use this Merge Request as inspiration for our own on-demand compilation implementation.

How to introduce on-demand compilation to Webpack

Before we dive deeper, let's provide a small recap for those who skipped the introduction.

Incremental compilation is a technique that enables the compiler to only recompile the parts of the code that have changed during rebuilds. This feature is supported by default in Webpack's watch mode, though the term is not explicitly mentioned in the documentation.

On-demand compilation is a technique that allows the compiler to only compile those parts of the code that are needed for certain functionality to work. In Webpack it is not supported by default. To implement on-demand compilation, multiple entrypoints (webpack.entry) are recommended. This allows us to specify which parts of the code need to be compiled. It should be noted that using a single entrypoint makes it difficult, but not impossible, to determine the necessary code and configure Webpack accordingly.

Our goal is to introduce on-demand compilation into our Webpack configuration. We will achieve this by demanding entrypoints that correspond to different pages within our application. Therefore, when working locally, you will only need to wait for the compilation of the pages that you are currently working on during the session. To illustrate, a sample Webpack configuration could resemble the following:

// Pages or larger parts of the application
const entries = ['index', 'catalog', 'profile', 'settings', 'search'];

const IS_PRODUCTION = process.env.NODE_ENV === 'production';

/**
 * Takes the entries and reduces them into key-value object with path to entrypoint
 * { "index": ["src/pages/index/client"]}
 */
function generateEntries() {
    if (IS_PRODUCTION) {
        return Object.values(entries).reduce((generatedEntries, entry) => {
            generatedEntries[entry] = path.join(__dirname, `./src/pages/${entry}/client`);
            return generatedEntries;
        }, {});
    }

    return Object.values(entries).reduce((generatedEntries, entry) => {
        generatedEntries[entry] = [
            'webpack-hot-middleware/client',
            path.join(__dirname, `./src/pages/${entry}/client`),
        ];
        return generatedEntries;
    }, {});
}

const config = {
    mode: IS_PRODUCTION ? 'production' : 'development',
    entry: generateEntries,
    output: {
        // every entrypoint converted into a separate bundle
        path: path.join(__dirname, './build'),
        filename: '[name].build.js',
        chunkFilename: '[name].bundle.js',
    },
    resolve: {
        plugins: [new TsconfigPathsPlugin({})],
        extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
    },
    module: {
        rules: [
            {
                test: /\.(js|ts|jsx|tsx)$/,
                loader: 'esbuild-loader',
                options: {
                    loader: 'tsx',
                    target: 'es2015',
                },
            },
            {
                test: /\.css$/,
                sideEffects: true,
                use: [
                    'style-loader',
                    'css-loader',
                ],
            },
        ],
    },
    optimization: {
        minimize: IS_PRODUCTION,
    },
    plugins: [],
    target: 'web',
    devtool: false,
};

module.exports = config;
Enter fullscreen mode Exit fullscreen mode

The sample configuration might or might not be valid - this is irrelevant to the topic.

Another crucial component in the puzzle is webpack-dev-middleware. Whether you have your own Node.js server or you're using webpack-dev-server for local development, both setups utilize webpack-dev-middleware.

webpack-dev-middleware provides access to an invalidate callback. This callback is responsible for invalidating the current Webpack configuration and initiating recompilation with the updated one. Consequently, the generateEntries function, defined in the configuration, is invoked again, allowing us to modify its results.

So the general idea is the following: use the development server to intercept requests and identify which pages need to be compiled. Store the list of compiled pages in a Set and invalidate webpack-dev-middleware. Hook into the generateEntries function and return only the entries that are present in the Set.

It is a great idea to incapsulate the logic for storing and filtering demanded entrypoints in a separate class which could look like this:

/**
 * Singleton which stores demanded entrypointsa nd filters those
 */
class WebpackOnDemandCompiler {
    #demandedEntries = new Set([]);

    static #instance = null;

    /**
     * @returns {WebpackOnDemandCompiler}
     */
    static getInstance() {
        if (!this.#instance) {
            this.#instance = new WebpackOnDemandCompiler();
        }
        return this.#instance;
    }

    /**
     * Should be invoked before returning value to `webpack.entries`
     * @typedef {string | string[]} Paths
     * @typedef {Record<string, Paths>} Entries
     * @param {Entries} entries
     */
    filterEntries(entries) {
        const filteredEntries = Object.entries(entries).reduce((acc, [page, paths]) => {
            if (this.#demandedEntries.has(page)) {
                acc[page] = paths;
            } else {
                // in some configurations integration with dev server will not work
                // unless entry is present from start
                // the file is empty so it does not add overhead
                acc[page] = "./empty-file.js"
            }

            return acc;
        }, {});


        return filteredEntries;
    }

    /**
     * Should be invoked when a new entry is requested
     * (e.g. `res.render` call or specific requests to `webpack-dev-server`)
     */
    handleEntry(entry, devMiddleware) {
        if (!this.#demandedEntries.has(entry)) {
            this.#demandedEntries.add(entry);

            devMiddleware.invalidate();
        }
    }
}

module.exports = WebpackOnDemandCompiler;
Enter fullscreen mode Exit fullscreen mode

It makes sense for such a class to be a singleton - so that the state is shared across integrations with dev server and inside Webpack configuration.

Modified generateEntries should look like this:

const WebpackOnDemandCompiler = require("./webpack-on-demand-compiler");

const entries = ["index", "catalog", "profile", "settings", "search"];

function generateEntries() {
  if (IS_PRODUCTION) {
    return Object.values(entries).reduce((generatedEntries, entry) => {
      generatedEntries[entry] = path.join(
        __dirname,
        `./src/pages/${entry}/client`
      );
      return generatedEntries;
    }, {});
  }

  const allEntries = Object.values(entries).reduce(
    (generatedEntries, entry) => {
      generatedEntries[entry] = [
        "webpack-hot-middleware/client",
        path.join(__dirname, `./src/pages/${entry}/client`),
      ];
      return generatedEntries;
    },
    {}
  );

  const onDemandCompiler = WebpackOnDemandCompiler.getInstance();

  return onDemandCompiler.filterEntries(allEntries);
}
Enter fullscreen mode Exit fullscreen mode

The only thing left is to integrate with the "demand" of the pages.

Integrating with webpack-dev-server

When integrating with webpack-dev-server you can use this Merge Request as inspiration.

Use devServer Webpack config property and its before hook. In the hook add a middleware that filters out requests made for entrypoints and invokes WebpackOnDemandCompiler.handleEntry providing it with the entry and server.middleware instance. The implementation could look like this:

const WebpackOnDemandCompiler = require("./webpack-on-demand-compiler");

const entries = ["index", "catalog", "profile", "settings", "search"];

const config = {
  // ...
  devServer: {
    before(app, server) {
      app.use((req, res, next) => {
        const fileName = path.basename(req.url);
        // {entryName}.build.js
        const entryName = fileName.split('.')[0];

        /**
         * Here you should filter only page entries
         * 
         *
         * Be sure to filter out hot update files that are for example named "{entryName}.[hash].hot-update.js"
         */
        if (entries.some(entry => entry === entryName) && fileName.endsWith('.build.js')) {
          const onDemandCompiler = WebpackOnDemandCompiler.getInstance();
          // pass `webpack-dev-middleware` instance
          onDemandCompiler.handleEntry(entryName, server.middleware);
        }

        next();
      });
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

With this code in place you should be good to go!

Integrating with a custom server

With a custom server we have to make a few assumptions on how the server actually works. In this text I will assume that the server uses webpack-dev-middleware and res.render callback to render pages (or something similar - depending on your library or framework).

While a central location like render function yields an easy integration with on-demand compilation. You could use a less straightforward approach with, for example, endpoints being assigned a page and triggering on-demand compilation when those endpoints are invoked.

Given the assumption mentioned earlier, res.render becomes perfect place to intercept requests made to pages and process them using WebpackOnDemandCompilation:

const onDemandCompiler = WebpackOnDemandCompiler.getInstance();

// Make sure to only apply this middleware in dev mode and after `webpack-dev-middleware`
const onDemandCompilerMiddleware = (req, res, next) => {
  const baseRender = res.render;

  res.render = (entry, ...args) => {
    onDemandCompiler.handleEntry(entry, res.locals.webpack.devMiddleware);
    return baseRender(entry, ...args);
  };
  next();
};
Enter fullscreen mode Exit fullscreen mode

Once you have implemented this code, you should notice a significant decrease in startup times (initially, only empty files will be compiled). When you request a new page, it will still take some time for compilation, but it will likely be much faster compared to performing a complete build during startup. Additionally, hot reload should also be quicker now!

It is important to keep in mind that as you navigate through your application, you will be requesting more and more pages. At a certain point, this can lead to a decrease in performance and a return to the pre on-demand compilation levels. To address this issue, you can implement a limit on the number of entrypoints that can be compiled at once. When this limit is reached, the least recently requested entrypoint should be removed from the list. By doing this, you can ensure that your development mode experience remains fast and efficient, regardless of the length of your session.

Conclusion

Due to the extensibility of Webpack very little information on the web about Webpack is actually relevant to a specific case. I tried to keep the guide as configuration-agnostic as possible - and I sure hope it was helpful in your specific case!

If you have any corrections or suggestions you can leave them in the comments. And I am also very interested in your both success and failure stories on speeding up Webpack and migrating from it.

Top comments (0)