Speeding up our .NET builds

We’ve all been there; waiting for builds that seem to take forever, especially when you’re working on applications that mix .NET backend code and javascript/css assets. Every small change triggers a full rebuild of everything, including npm packages that haven’t changed in weeks.

Recently, a teammate introduced a simple but clever solution that cut our build times dramatically, a custom BUILD_CACHE property that intelligently decides when to rebuild frontend assets.

The Problem

The project is an large monolith application with many .NET projects and quite a few of them need to build javascript and/or css assets.

The issue? Every build had expensive asset rebuilds. npm ci alone can take several minutes on a fresh install, and npm run build adds even more time for webpack to do its thing.

The Solution

The fix was surprisingly elegant. Instead of always running the full npm build process, we created conditional MSBuild targets.

<Target Name="PreBuild" AfterTargets="PreBuildEvent" Condition="'$(BUILD_CACHE)' != 'true'">
  <Exec Command="npm ci" />
  <Exec Command="npm run build" />
</Target>
<Target Name="PreBuildCached" AfterTargets="PreBuildEvent" Condition="'$(BUILD_CACHE)' == 'true'">
  <Exec Command="node ../../if-changes.js" />
</Target>

When BUILD_CACHE=true, instead of blindly rebuilding everything, we run a custom Node.js script called if-changes.js that intelligently checks what’s actually changed.

How It Works

The magic happens in the package.json configuration of the various projects. We add the following onlyIfChanged section to it:

"onlyIfChanged": [
  {
    "name": "dependencies",
    "globs": [
      "package-lock.json"
    ],
    "command": "npm ci"
  },
  {
    "name": "build",
    "globs": [
      "Scripts/**/*",
    ],
    "command": "npm run build"
  }
]

The script uses file globbing (powered by fast-glob) to check specific files and directories, only when these files change does it trigger the associated commands. No more rebuilding frontend assets when you’ve only touched C# code.

Setting It Up

Here is how to set it up on both Rider and Visual Studio.

For Rider users:

Settings → Build, Execution, Deployment → Toolset and Build
Edit "MSBuild Global Properties"
Add BUILD_CACHE=true

For Visual Studio users:

There is no built in way to do this in Visual Studio so we need to make use of environment variables within Windows.

Open System Properties → Advanced → Environment Variables
Add BUILD_CACHE=true as a system variable
Restart your computer (yes, really!)

Add the conditional targets to your .csproj file shown above and configure the onlyIfChanged section in your package.json file. Don’t forget to install fast-glob as a dependency.

The Results

The speed improvement was immediate and significant. Local development builds that used to take 5-10 minutes now complete in 1 and developers stopped grabbing coffee during every build cycle (not sure if that is a win or a lose).

The results on my MacBook Pro with an M1 chip and 16GB of RAM. The gap was even more noticable on others running Windows… just saying :P

With cache    Build completed in 00:01:01.969
Without cache Build completed in 00:07:09.369

I’m reposting this one of the most underrated inventions of our time. I’ve gone from Mobi effectively not starting up on my machine anymore to it starting up within seconds. ~ One happy customer in our slack channels

The best part? It’s completely transparent. Developers who haven’t set up the caching still get working builds, they just take longer. Those who enable caching get the speed benefits without any downsides. Sometimes the best optimisations aren’t about fancy new tools or frameworks; they’re about being smart about when you choose to skip work entirely.

Bonus

Here is a link to the GitHub gist of our if-changes.js file.

Development .NET
How I built this blog