Skip to content

Turborepo vs Nx: The Question I Ask Before Either One

Turborepo vs Nx: The Question I Ask Before Either One

A client asked me last month which one they should use, Turborepo or Nx, and I gave the answer I always give, which is “it depends”, which is a cowardly answer and I knew it while I was saying it. So I went away and worked out what it actually depends on.

It is not speed. Both are fast. It is not caching either, because they both cache task outputs locally and both can share that cache with your CI. Every comparison I read spent most of its length on those two things and neither one has been the deciding factor on a single project I have worked on.

The thing it depends on is whether you want a tool that reads your package.json scripts, or a tool that reads your source code. That sounds like a technicality. It is the whole decision, and everything else downstream of it, including the parts that will annoy you in month six.

Turborepo runs scripts. Nx understands projects.

Turborepo’s model is deliberately shallow. You have packages. Packages have scripts. You tell Turborepo how those scripts relate to each other and it works out the order and the cache keys.

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    }
  }
}

The ^build means “build my dependencies first”. Turborepo learns what depends on what from your workspace’s package.json dependency fields and your lockfile. It does not open your TypeScript files. As far as it is concerned, a package is a directory with a name and a set of scripts.

Nx does the same job and then keeps going. It parses your imports and builds a project graph from the actual code, not just the manifest. The pipeline config looks superficially similar:

// nx.json
{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build", "prebuild"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

The task pipeline docs walk through how it expands that into a task graph. Same caret, same idea. The difference is what feeds it. Nx knows that apps/web/src/checkout.ts imports from libs/pricing, even if nobody declared that in a package.json, because it read the import statement.

That difference produces everything else.

What you get for letting the tool read your code

Finer-grained affected detection, mostly. nx affected can look at a diff and tell you that only two of your forty projects need testing, because it traced the import graph from the changed files. Turborepo can do a version of this via its own affected query, but the resolution is package-level: change one file in a package and every task in that package, and everything downstream, misses cache.

On a repo with a handful of chunky packages, that distinction does not matter. On a repo with sixty small libraries, it is the difference between an eight minute CI run and a ninety second one.

You also get module boundary enforcement, which I have come to like more than I expected. Nx can fail lint when a feature library imports from another feature library it was not tagged to touch. That is the kind of rule that lives in a wiki page nobody reads, right up until you put it in CI.

The cost is that Nx needs to understand your technology to do any of this. That is what the plugins are for. It has good ones for the things it has good ones for, which is a long list, and it has nothing for the things it has nothing for. If your monorepo is a Next.js app, a Go service, and a Python worker sharing a repo for deployment convenience, half of Nx’s value proposition does not apply to two thirds of your code.

The Turborepo cache gotcha nobody warns you about

Here is a thing that cost me most of an afternoon and does not show up in any comparison post.

Turborepo builds two hashes: a global hash and a per-package hash. If the global hash changes, everything misses cache. Everything. And the global hash includes the source files of every internal package that your root package.json depends on, directly or transitively. The caching documentation spells this out, and I recommend reading it before you wire anything up rather than after, as I did.

So: someone adds @repo/tooling as a root dev dependency because it has a nice ESLint config. Six weeks later, every one line change in that package invalidates the cache for all forty tasks in the repo, and your CI times quietly double, and nobody connects the two events because they are six weeks apart.

# this is the command that finally told me why
turbo build --dry
# and this one, to diff two runs and see which input changed
turbo build --summarize

--summarize writes a JSON file listing every input to every task hash. Diffing two of them is unglamorous and it is the only reliable way I have found to answer “why did this miss cache”. The run summary also names the reason, which for the case above shows up as RootInternalDepChanged.

Nx has the same class of problem with its namedInputs and sharedGlobals. Every caching tool has a version of this. The difference is that Turborepo’s failure mode is repo-wide and silent, and Nx’s tends to be project-scoped and noisier.

Remote caching is where the money question lives

Both do it. The mechanics differ in a way that matters if you care about who holds your build artifacts.

Turborepo’s remote cache defaults to Vercel with zero config, and the protocol is a documented HTTP API, so self-hosting is a real option rather than a theoretical one. There are community implementations. I have run one on a small VPS for a client who did not want build outputs leaving their infrastructure, and it was uneventful in the way you want infrastructure to be.

Nx’s remote caching runs through Nx Cloud. Self-managed options exist and they sit behind enterprise pricing. If your team is three people and your CI runs eleven minutes, this is fine and free either way. If you are twenty people and your CI is the reason nobody wants to open a pull request on Friday, price both before you commit, because the number moves a lot.

One recent Turborepo addition I did not expect to care about: it now shares the local filesystem cache between git worktrees. If you keep two branches checked out at once, which I do constantly, a build in one worktree restores instantly in the other. Small thing. Noticeable thing.

How I actually choose now

Three questions, in order, and I stop at the first clear answer.

Does your repo contain more than one language, or a language Nx has no plugin for? Turborepo. It treats everything as scripts, and scripts are language agnostic. This is the case for most of the repos I get handed.

Do you have more than roughly thirty projects, all in the JavaScript and TypeScript family, and is CI time an actual complaint people make out loud? Nx. The finer affected graph starts paying for the plugin ceremony somewhere around there, and the module boundary rules start paying for themselves at about the same size.

Neither of those? Turborepo, because the config file fits on a screen and you can hand it to a new hire without a tutorial. I have migrated a repo from Turborepo to Nx exactly once and it took two days. I have never migrated the other direction, but I assume it is easier, because you would be deleting things.

The reason I lead with the language question rather than the size question is that it is the one that cannot be fixed later. You can grow into Nx. You cannot make Nx understand your Go service by wishing at it.

The part where I admit something

For about a year I dismissed Nx as enterprise-flavoured over-engineering. That was a reaction to the marketing rather than the tool, and it was wrong. On a large TypeScript repo the project graph does something Turborepo structurally cannot, and pretending otherwise was me defending a preference rather than making an argument.

What I still push back on is the assumption that you need either one. If you have four packages and your full build takes ninety seconds, npm run build --workspaces is fine. Adding a task runner to a repo that does not have a task ordering problem buys you a config file to maintain and a new failure mode to learn. I made this mistake in the other direction too, and I wrote about the same instinct when I looked at what a bundler migration actually costs. The migration is never the number in the README.

If you want the longer version of how I think about tooling decisions on client projects, that reasoning shows up throughout the platform work I do.

Try this on your repo this week

Before you pick anything, measure the thing you are trying to fix. Run your full build cold and write down the number. Then run it again warm and write down that number. If the gap is small, your problem is not task orchestration and neither tool will help you.

If the gap is big, install Turborepo first, even if you suspect you want Nx. It takes about twenty minutes on an existing pnpm or npm workspace, the config is one file, and it will tell you within a day whether package-level cache granularity is good enough for your repo. If it is, you are done and you saved yourself the plugin setup. If it is not, you now have a specific, measurable reason to move to Nx, which is a much better reason than a blog post.

Then run turbo build --summarize once and look at what is in the global hash. You will find something in there that surprises you. Everyone does.