If you are facing slow build times in your Nx monorepo and you have a lot of projects, incremental builds can help you gain back some of the lost time. In this article, we will explore how incremental builds work with Nx and how you can leverage them to speed up your development process.

What are incremental builds?

The basic idea behind an incremental build is to only rebuild the parts of your codebase that have changed since the last build. This can significantly reduce the time it takes to build your project, especially if you have a large codebase with many projects and libraries.

Nx has a local cache, where each task and its outputs are stored. When you run a cacheable task twice in Nx, the second time it will be much faster because it will reuse the outputs from the first run. Incremental builds take this concept one step further by having build targets on a lot of dependent projects, such that all these build outputs of each project with a build target can be reused in the next build.

Disclaimer

Incremental builds are not a silver bullet solution and they might not work well for all projects and you certainly should not make every library buildable. Reason being, that cold builds are slower with incremental builds, because more projects are built from scratch. In case you have a lot of churn on some library or the library, it probably should not be buildable.

But still, incremental builds can be a great tool to speed up your development process and make your builds more efficient if applied carefully. Great candidates for buildable libraries are shared libraries that have low churn rates and are used by many other projects. A good example would be a UI library or a design system library that is used across multiple applications.

Enabling incremental builds

If you are using one of Nx's executors, such as @nx/angular:webpack-browser, you can enable incremental builds by setting the buildLibsFromSource option to false in the project.json file of the application that should support incremental builds.

{
  "targets": {
    "build": {
      "executor": "@nx/angular:webpack-browser",
      "options": {
        "buildLibsFromSource": false
      }
    }
  }
}

This will tell Nx to not build the dependent libraries with a build target from source, but to use the outputs from the previous build instead. By the way, Nx is computing a task graph and if you have a buildable libraries, those will be built in parallel, before the application is built. And in case, the build of the library is not needed, because of a cache-hit, the library will not be built at all, potentially saving a lot of build time.

Incremental Builds

That said, you need buildable libraries to make incremental builds make sense.

Buildable libraries

By default, a library generated with Nx is not buildable, unless you specifically add the buildable: true flag to the library generator. The difference between a buildable and a non-buildable library is that a buildable library has a build target. This build target could use whatever executor you want, but if you are using Angular, you would probably use the @nx/angular:ng-packagr-lite executor.

Creating a buildable library is as simple as adding the buildable: true flag to the library generator:

nx g @nx/angular:library my-lib --buildable

If you want to make an already existing non-buildable library buildable, you can manually do the work, which the library generator would do for you:

  1. Add the build target to the project.json file of the library
  2. Create a package.json file in the library folder with the following content
  3. Create a ng-package.json file if the library contains Angular code

If you want to get an idea, how these files should look like, you can have a look at this project.

Rules

In order to make incremental builds work, you need to follow some rules:

  1. You must have all ts path aliases in the root tsconfig.base.json file. Project-level ts path aliases are not allowed.
  2. A buildable library must not have a dependency on a non-buildable library.

If you happen to depend on a non-buildable library, you will get a linting error if you have the nx/enforce-module-boundaries rule enabled.

How it works under the hood

First of all, when you run a build on an application with buildLibsFromSource set to false, Nx will build all buildable libraries first, or skip the build if it can be reused from the cache. After that, Nx will temporarily modify the tsconfig.base.json file to override the paths for the buildable libraries to point to the dist folder of the library.

{
  "compilerOptions": {
    "paths": {
-     "@my-org/my-lib": ["libs/my-lib/src/index.ts"]
+     "@my-org/my-lib": ["dist/libs/my-lib"]
    }
  }
}

Let's now have a look at how Nx is doing it under the hood. Here you can see the code that is responsible for creating the temporary tsconfig file.

import { readCachedProjectGraph, type ExecutorContext } from '@nx/devkit';
import {
  calculateProjectDependencies,
  createTmpTsConfig,
  type DependentBuildableProjectNode,
} from '@nx/js/src/utils/buildable-libs-utils';
import { join } from 'path';

export function createTmpTsConfigForBuildableLibs(
  tsConfigPath: string,
  context: ExecutorContext
) {
  let dependencies: DependentBuildableProjectNode[];
  const result = calculateProjectDependencies(
    context.projectGraph ?? readCachedProjectGraph(),
    context.root,
    context.projectName,
    context.targetName,
    context.configurationName
  );
  dependencies = result.dependencies;

  const tmpTsConfigPath = createTmpTsConfig(
    join(context.root, tsConfigPath),
    context.root,
    result.target.data.root,
    dependencies
  );
  process.env.NX_TSCONFIG_PATH = tmpTsConfigPath;

  const tmpTsConfigPathWithoutWorkspaceRoot = tmpTsConfigPath.replace(
    context.root,
    ''
  );

  return { tsConfigPath: tmpTsConfigPathWithoutWorkspaceRoot, dependencies };
}

The interesting part is the createTmpTsConfig function, which is creating the temporary tsconfig file. More buildable libs utils are located here.

export function createTmpTsConfig(
  tsconfigPath: string,
  workspaceRoot: string,
  projectRoot: string,
  dependencies: DependentBuildableProjectNode[],
  useWorkspaceAsBaseUrl: boolean = false
) {
  const tmpTsConfigPath = join(
    workspaceRoot,
    'tmp',
    projectRoot,
    process.env.NX_TASK_TARGET_TARGET ?? 'build',
    'tsconfig.generated.json'
  );
  if (tsconfigPath === tmpTsConfigPath) {
    return tsconfigPath;
  }
  const parsedTSConfig = readTsConfigWithRemappedPaths(
    tsconfigPath,
    tmpTsConfigPath,
    dependencies,
    workspaceRoot
  );
  process.on('exit', () => cleanupTmpTsConfigFile(tmpTsConfigPath));

  if (useWorkspaceAsBaseUrl) {
    parsedTSConfig.compilerOptions ??= {};
    parsedTSConfig.compilerOptions.baseUrl = workspaceRoot;
  }

  writeJsonFile(tmpTsConfigPath, parsedTSConfig);
  return join(tmpTsConfigPath);
}

The readTsConfigWithRemappedPaths function is responsible for reading the original tsconfig file and modifying the paths for the buildable libraries. It basically spreads the dependencies which are buildable libraries and replaces those paths in the ts config paths.

You can see the temporary tsconfig file in the tmp folder of your workspace, if you are really quick, because it is being deleted afterwards. As you can see now, it all depends on stitching together the modified paths with the tsconfig.base.json file, therefore it is important to have all paths in the tsconfig.base.json file.

Conclusion

I hope this article gave you a good overview of how incremental builds work with Nx and how you can leverage them to speed up your development process. You should now have a clear understanding of what incremental builds are, how to enable them in your project, and how they work under the hood. You should also know, that they are no one size fits all solution and you should carefully consider which libraries you make buildable.

A good rule of thumb is to make shared libraries buildable, as they are used across multiple projects and have a low churn rate.

"Opinions are my own and not the views of my employer."