Scaling Large TypeScript Monorepos: How We Cut CI Times by 90% with Nx

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The 2:15 AM Hotfix Nightmare

It was a Tuesday morning, and the clock hit 2:15 AM. A critical bug had just surfaced in production, crashing the checkout page for roughly 15% of our users. I had the fix ready in five minutes—a simple one-line change in a shared validation utility. I pushed the code and waited for the green light.

Instead of a quick deploy, I watched the CI pipeline spin. Ten minutes passed. Then twenty. Then thirty. Our monolithic TypeScript repository was rebuilding everything: the React storefront, the NestJS API, the admin dashboard, and over 30 shared libraries. All of this because of a single character change in a utility folder.

By the time the fix reached production, 45 minutes had vanished. In a high-stakes environment, that delay is more than just annoying; it’s expensive. This is the “monorepo tax.” As your codebase grows, build times usually increase linearly until they eventually crush your team’s momentum.

The Root Cause: The “Build Everything” Fallacy

Many teams start a monorepo by tossing folders together using Lerna or basic npm workspaces. Usually, the build script is a blunt instrument: "build": "npm run build --workspaces". This works fine when you have two apps. It fails the moment you scale.

The real issue is a lack of dependency awareness. Most build systems treat a repository as a flat list of projects. They don’t realize it is actually a Directed Acyclic Graph (DAG). If your system doesn’t know that App A depends on Lib B, but App C is entirely unrelated, it defaults to the safest, slowest route: rebuild everything.

Transitioning from a senior developer to an architect requires a shift in perspective. You have to stop focusing solely on the code. You must start optimizing the infrastructure that delivers it.

How Nx Maps Your Architecture

Nx isn’t just a task runner. It is a smart build system designed to understand the relationships between your projects. While tools like Turborepo are popular for their speed, Nx offers a deeper ecosystem for multi-framework TypeScript projects. It allows you to mix React, Angular, and Node.js while maintaining a strict, searchable graph of dependencies.

Visualizing the Complexity

Before you can optimize, you need to see the spaghetti. Nx includes a built-in tool to visualize your project graph:

npx nx graph

This command opens an interactive map of your entire architecture. You can see exactly which libraries are tightly coupled and which are isolated. This graph acts as the “brain” Nx uses to decide what needs to be tested and what can be ignored.

Strategy 1: Only Build What Changed

The fastest way to speed up a build is to not run it at all. This is where nx affected comes in. Instead of running tasks for every project, Nx compares your current git branch against a base (like main). It then calculates the minimum amount of work required.

If I modify a function in libs/shared-ui, Nx checks the graph. If only the storefront app imports that library, Nx skips the builds for the backend-api and admin-panel entirely.

Optimizing Your CI Workflow

Update your CI configuration—whether you use GitHub Actions, GitLab, or Jenkins—to stop using generic build commands. Switch to the affected syntax:

# Don't do this: npx nx run-many -t build

# Do this: Only build projects impacted by your PR
npx nx affected -t build --base=origin/main

When we implemented this, our average CI time dropped from 45 minutes to about 12 minutes. That is a massive win for developer productivity.

Strategy 2: Computation Caching

Even with affected commands, you often find yourself rebuilding code that hasn’t changed. Perhaps you switched back to a previous branch, or a teammate already built the exact same version of a library. Nx solves this using a content-addressing hashing algorithm.

It creates a hash based on your source files, environment variables, and tool versions. If that hash matches a previous run, Nx simply pulls the results from the cache. It feels like magic when a full build finishes in 200ms because every task was a “cache hit.”

The Problem with Local Caches

By default, this cache lives in node_modules/.cache/nx on your local machine. This helps you, but it doesn’t help your team. Every time a CI runner starts a new job, it’s a “cold” environment with zero history. To fix this, you need Remote Caching.

Sharing the Cache via Nx Cloud

Nx Cloud lets your entire organization share a single cache. When the CI builds the main branch, it uploads the artifacts. When you pull the latest code to your laptop and run a build, your machine downloads the pre-built files in seconds. You are essentially leveraging the work the CI already did.

To set this up, run:

npx nx connect-to-nx-cloud

This adds an accessToken to your nx.json. Now, every build artifact is stored in the cloud. It’s a shared dist folder that stays perfectly in sync across the whole company.

Managing Multi-Framework Projects

Large monorepos often struggle with different build requirements. You might have a NestJS backend using SWC and a React frontend using Vite. Nx handles this through Executors.

You define how each project should behave in a project.json file:

{
  "name": "api-gateway",
  "targets": {
    "build": {
      "executor": "@nx/js:swc",
      "outputs": ["{options.outputPath}"],
      "options": {
        "outputPath": "dist/apps/api-gateway",
        "main": "apps/api-gateway/src/main.ts"
      }
    }
  }
}

By abstracting the build logic, Nx ensures the caching mechanism remains consistent. Whether you use Webpack, Rollup, or Vitest, the logic remains the same: if the inputs haven’t changed, fetch the output from the cache.

Advanced CI: Distributed Task Execution (DTE)

To reach peak performance, your CI needs to be “graph-aware.” A common mistake is running tasks in a single serial block. Instead, use Distributed Task Execution (DTE).

DTE coordinates multiple CI agents. Instead of one machine struggling with 10 builds, Nx Cloud tells Agent A to build Lib 1. It tells Agent B to build Lib 2. Agent C starts App 1 the moment its dependencies are ready. This is intelligent orchestration, not just simple parallelization.

GitHub Action Example

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: nrwl/nx-set-shas@v4
      - run: npm ci
      - run: npx nx-cloud start-agent
      - run: npx nx affected -t build lint --parallel=3

The Outcome: 45 Minutes Down to 4

After we tightened our project boundaries and enabled remote caching, the results were night and day. That same 2 AM hotfix that used to take 45 minutes now deploys in under 5 minutes. Most of that time is just the overhead of GitHub Actions spinning up a virtual machine.

The impact on team morale is just as significant. When builds are fast, developers commit smaller chunks of code and run tests more frequently. The dread of a “broken CI” completely evaporates.

Best Practices for Success

  • Granular Libraries: Smaller libraries mean more effective nx affected commands. Don’t create a “utils” folder; create a validation-utils library.
  • Enforce Boundaries: Use Nx tags to prevent circular dependencies. If your UI library starts importing database models, your build graph will become a bottleneck.
  • Automate the Cache: Use a remote cache provider like Nx Cloud or an S3 bucket. Local-only caching is only half the solution.
  • Audit the Graph: Run nx graph monthly. Look for “god-libraries” that every project depends on. If you touch a library that 50 apps use, you’ve just triggered 50 builds.

Scaling a TypeScript monorepo is a constant effort to keep dependencies clean. However, with a solid understanding of your project graph and the right caching strategy, you can keep your deployment speed high even as your codebase grows into the millions of lines.

Share: