Nx, the task graph and the cache in a monorepo
Nx builds a dependency graph between projects in one repository, runs only the tasks a change actually touched, and remembers results so the same work is never computed twice. Version 23.1.1 of the nx npm package was published on 30 July 2026 under MIT. What costs money is a separate service next to the tool, not the tool itself, and that distinction is where most of the disappointment comes from.
What Nx actually does
Nx reads the repository and derives two graphs. The first is the project graph: who depends on whom, resolved from imports in the code and from implicitDependencies fields. The second is the task graph, where a node is a project plus target pair and edges come from dependsOn. The first one powers nx affected, the second one determines execution order and parallelism.
Caching sits on top. Before running a task, Nx hashes its inputs, meaning the files selected by inputs and namedInputs, dependency versions and environment variables. If it has seen that hash before, it restores the files declared in outputs and replays the recorded terminal output instead of doing the work. The local cache lands in .nx/cache by default and graph metadata in .nx/workspace-data. The path is changed by the cacheDirectory field in nx.json or the NX_CACHE_DIRECTORY variable. Implementation-wise this is a database handled by a native module, not a plain directory of archives.
The third layer is generators and plugins. A generator creates or modifies files, a plugin can detect a tool's configuration inside a project and expose targets nobody typed by hand. Version migrations are handled by nx migrate, which writes a change plan to a file and lets you run it separately.
What Nx does not do: it does not build code. Compilation belongs to the lower layer, Vite, esbuild or Rspack, and Nx only decides which of them to invoke, in what order, and whether the result can be restored from cache. It does not check types or format code either, so TypeScript and Biome stay in the pipeline independently.
# install and initialize inside an existing repository
npm install --save-dev nx
npx nx init
# tasks only for projects affected relative to the base branch
npx nx affected -t build test --base=main --head=HEAD
# the same across all projects, with explicit parallelism
npx nx run-many -t lint --parallel=4
# inspect the graph and the full configuration of one project
npx nx graph
npx nx show projects
npx nx show project my-library --json
# diagnostics: skip the cache, clear state, repair configuration
npx nx build my-library --skip-nx-cache
npx nx reset
npx nx repairLicense: MIT at the core, proprietary right next to it
Checking three sources gives a consistent picture for Nx itself and an inconsistent one for the service beside it.
In the nrwl/nx repository on the master branch there is a LICENSE file holding the MIT text and the notice „Copyright (c) 2017-2026 Narwhal Technologies Inc.". There are no LICENSE.md, LICENSING.md, NOTICE or COPYING files at the root, verified by fetching them directly. The license field in the npm registry for version 23.1.1 reads MIT. The unpacked package contains package/LICENSE with the same MIT text plus real code: 1256 files and 17,187,924 bytes unpacked, the vast majority of that inside the dist/src directory. All three places agree. The ten optional native binary packages, one per operating system and architecture pair, declare MIT as well and carry the same LICENSE file.
The nx-cloud package looks different. Its newest version is 19.1.3 from 31 March 2026, and the license field reads proprietary. That declaration alone is unusual, because closed-source packages more often omit the field entirely. The surprise is inside the tarball: the package/LICENSE file holds no software license at all, only the full text of Creative Commons Attribution-NoDerivs 3.0 Unported, a license meant for content. The same reference is repeated in README.md. The metadata declaration and the file in the package therefore say two different things.
What follows in practice. CC BY-ND allows copying and redistributing the work unchanged, requires keeping the attribution notice, and explicitly withholds the right to create adaptations, which for a program means no right to modify and no right to redistribute modified versions. The proprietary value in metadata grants no rights beyond whatever a separate agreement with the vendor provides. Neither path carries a conversion date into an open license of the kind BSL-style licenses use. If you keep a dependency license list at your company, record both values and flag the discrepancy, because a scanner reading only the license field will report something different from a scanner reading files inside the package.
One more separation is worth making. The nx-cloud package weighs 1,594,351 bytes unpacked and holds exactly 11 files. That is not the engine of the service, it is a thin client. The real client code is downloaded at runtime: the dist/src/nx-cloud/update-manager.js module inside the Nx core queries /nx-cloud/client/verify at https://cloud.nx.app and then fetches and extracts the bundle it is pointed at. The address is overridden by the NX_CLOUD_API variable. On top of that, the MIT-licensed nx package itself exposes two commands in its bin field: nx and nx-cloud. Installing the open tool therefore hands you a ready entry point into the closed service, although without an account it does nothing.
A separate family is the paid self-hosted cache plugins: @nx/s3-cache, @nx/gcs-cache, @nx/azure-cache and @nx/shared-fs-cache. All of them at version 5.0.7 declare a license value of Commercial, all depend on the @nx/key package used for license activation, and all set the nx peer dependency range to >= 18 < 23. With Nx 23.1.1 that range is not satisfied, so the package manager reports a conflict. The most recent release in this family is from 22 May 2026.
Nx Cloud pricing and the free plan limit
The Nx Cloud pricing page is built on Framer and part of its content, including the collapsed answers in the questions section and the feature markers in the comparison table, is absent from the raw HTML. The numeric values on the plan cards are available without JavaScript, and those are the ones I quote.
| Plan | Monthly credits | Contributors | Concurrent CI connections | Add-ons |
|---|---|---|---|---|
| Hobby | 50,000, resets monthly | up to 5 | 10 | none, free plan |
| Team | 50,000 included | 5 included | 10 included | 19 USD per contributor, 5.50 USD per 10,000 credits, 2.25 USD per connection |
| Enterprise | custom quote | unlimited | custom quote | custom quote |
The Team plan card says „Starts at $0", which means there is no base fee and you pay only for what exceeds the included amounts. A contributor is defined on the page as any person or actor who authored the commit of a CI pipeline execution within the current billing cycle, so the counter tracks neither team headcount nor account count.
The arithmetic works out like this: 50,000 credits at 5.50 USD per 10,000 credits corresponds to 27.50 USD of value, and a single credit costs 0.00055 USD. How many credits a given task consumes depends on the resource class, and that cannot be read from the raw HTML of the pricing page; the vendor points to a separate credit consumption page. I do not quote numbers I could not confirm.
The Enterprise plan lists features unavailable below it: conformance rules, circular dependency detection, cross-repository visibility, SSO, and single-tenant or on-premises installation. Local installation is therefore possible, but only at that tier and after a sales conversation.
A remote cache without a vendor account
An open alternative exists and is built into the MIT core. In dist/src/tasks-runner/cache.js there is a branch that, when NX_SELF_HOSTED_REMOTE_CACHE_SERVER is set, constructs an HttpRemoteCache instead of the service client. Requests go through a native Rust module, which is why the code re-emits a warning about disabled certificate verification when you set NODE_TLS_REJECT_UNAUTHORIZED to zero. In the WebAssembly build this path does not work and Nx says so in a log message.
The protocol is documented as an OpenAPI 1.0.0 specification titled „Nx custom remote cache specification". It comes down to two operations on the /v1/cache/{hash} path: put uploads a task output, get retrieves it, authentication uses a bearerToken, and the body is a binary tar archive. You can write the server in anything. This is a genuine exit route for a team that wants a shared cache but does not want a vendor account.
# your own cache server following the vendor's OpenAPI specification
export NX_SELF_HOSTED_REMOTE_CACHE_SERVER="https://cache.internal-network.example"
export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN="$CACHE_TOKEN"
# upper bound on local cache size
export NX_MAX_CACHE_SIZE="10gb"
# skip the remote cache once while keeping the local one
npx nx run-many -t build --skip-remote-cacheThere is also the MIT-licensed nx-remotecache-custom package, which lets you plug in your own storage. Its newest version is 20.0.0 with an nx peer dependency range of ^20.0.0, three major releases behind current Nx. I would treat it today as a dead end rather than a fallback plan.
Configuration: nx.json and project.json
The nx.json file at the root describes behaviour for the whole repository. Below are fields actually present in the schema shipped with version 23.1.1.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"defaultBase": "main",
"parallel": 3,
"useDaemonProcess": true,
"useInferencePlugins": true,
"cacheDirectory": ".nx/cache",
"neverConnectToCloud": true,
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"cache": true,
"inputs": ["default", "^production"]
}
},
"plugins": [
{
"plugin": "@nx/vite/plugin",
"include": ["packages/*"],
"options": {}
}
]
}Setting neverConnectToCloud to true is a convenient safety catch while the decision about the service has not been made. Next to it the schema knows nxCloudAccessToken, nxCloudUrl and nxCloudEncryptionKey.
A single project is described by project.json, unless plugin detection is enough. The target field names come straight from the schema as well.
{
"name": "my-library",
"root": "packages/my-library",
"sourceRoot": "packages/my-library/src",
"projectType": "library",
"tags": ["scope:shared"],
"implicitDependencies": ["eslint-config"],
"targets": {
"build": {
"command": "vite build",
"cache": true,
"outputs": ["{projectRoot}/dist"],
"dependsOn": [{ "dependencies": true, "target": "build" }]
},
"serve": {
"command": "vite dev",
"continuous": true
}
}
}A target described by the command field is shorthand for the built-in command runner. The alternative is executor, pointing at a function from a plugin, together with options, configurations and defaultConfiguration. The second form gives you more but ties the configuration more tightly to Nx, which matters below.
When Nx pays off and when it is too much machinery
The gain from Nx is the product of two things: the share of tasks that can be skipped, and how long one task takes. With a single package the first factor is zero, because every change touches the only project, so affected always returns everything. What remains is the cache alone, which on a build lasting a dozen or so seconds saves a dozen or so seconds, and only when the exact same input repeats.
On the other side sits a fixed cost that can be counted more precisely. The installation is 17.2 MB and 1256 files of the unpacked nx package, plus one of ten optional native binary packages picked for your system and architecture. The dependency list holds 120 entries pinned to exact versions. The repository gains an nx.json file, a .nx directory that belongs in the ignore rules, and a background daemon you occasionally have to kill via nx reset. Then there is the learning cost: inputs, namedInputs, outputs, targetDefaults and target-inferring plugins add up to a few hours of reading for every new team member.
The threshold cannot be stated as a single number and I found no measurement I could honestly cite, so I label this plainly as a rule of thumb rather than a measured fact: the graph layer starts to earn its place once the repository holds a dozen or more tasks, a full run is measured in minutes, and a typical change touches a minority of projects. If any one of those three conditions fails, you are adding configuration without a return. The biggest disappointment starts exactly here: the local cache works immediately, but it only helps the person who already built that state once. The real jump arrives only when the cache is shared across every machine and the CI server, and that is either the service with the pricing above, or your own HTTP server that somebody has to write and maintain.
Nx, Turborepo and the build layer
Turborepo, covered separately in the Turborepo article, solves the same problem with a narrower set of means. Below are the differences that actually change the decision.
| Feature | Nx 23.1.1 | Turborepo 2.10.11 | Vite, esbuild, Rspack |
|---|---|---|---|
| Layer | task orchestration and code generation | task orchestration | compiling a single package |
| Core license | MIT | MIT | MIT or Apache 2.0 depending on the tool |
| Local cache | yes, in the core | yes, in the core | own, internal to the tool |
| Free remote cache | HTTP server per the OpenAPI specification, written by you | documented protocol, self-hosted deployments available | not applicable |
| Distributing tasks across machines | paid service only | absent from the tool | not applicable |
| Generators and version migrations | yes, nx generate and nx migrate | absent | absent |
| Inferring targets from tool configuration | yes, through plugins | no, tasks come from package.json | not applicable |
| Footprint in the repository | nx.json, .nx, optionally project.json | one configuration file | the tool's configuration file |
Putting Nx next to Vite or esbuild is misleading, because these are different floors. Nx will not replace a bundler and uses one itself. The sensible comparison is whether you need a layer above the bundler, not which bundler to pick. The same goes for tests, where Nx merely invokes Vitest and caches its result.
What Nx leaves in your repository and how to leave it
The footprint is larger than one file. Beyond nx.json and the .nx directory there are project.json files in projects where detection is not enough, migration entries generated by nx migrate, and configurations written by generators to Nx conventions. Two things create the strongest attachment: targets inferred by plugins, which exist nowhere in package.json, and targets based on executor, meaning a function supplied by a plugin rather than a command you can type in a terminal.
Leaving is feasible and looks roughly like this. First nx show project <name> --json for every project, to see the full expanded target list including the automatically inferred ones. Then rewriting each target as a plain script in package.json, where targets using command port over almost verbatim, while targets using executor require reconstructing the correct tool invocation with its options. Finally, deleting nx.json, the .nx directory, the project.json files and the dependencies. The difficulty is directly proportional to how much you leaned on executors and generators instead of bare commands. A team that writes targets as command from the start gets out in one afternoon. A team with elaborate executors and homegrown generators loses considerably more.
Common mistakes
Undeclared outputs is the most frequent trap. The task lands in the cache, on repetition Nx reports a hit and runs nothing, but restores no result files because it does not know where they are. The symptom is a green run with an empty output directory.
Overly broad inputs produce the opposite effect: the hash changes whenever anything in the project is modified, so hits never happen. The usual cause is a missing production input filtering test files out of the build target.
A shallow clone breaks nx affected. Without history reaching the base branch, the --base comparison has nothing to compare against and the result is arbitrary. CI configuration has to raise the fetch depth explicitly.
Installing a paid cache plugin on Nx 23 ends in a peer dependency conflict, because the @nx/*-cache family at version 5.0.7 declares nx in the >= 18 < 23 range. That is not a misconfiguration, it is the absence of a release supporting current Nx.
The last one is the illusion that the local cache is already the whole gain. Without shared storage, every machine and every CI run starts from zero.
FAQ
Is Nx free?
The tool itself is. Package nx at version 23.1.1 is MIT in the registry, in the repository file and in the file inside the tarball. Free are the graph, affected, the local cache, generators, migrations and the @nx/* framework plugins. Paid are the vendor's remote cache, distributing tasks across machines, and the self-hosted cache plugins carrying a Commercial license.
How does the nx package differ from nx-cloud?
nx is the open tool with its code in the tarball. nx-cloud at version 19.1.3 is a thin client of eleven files, with a license field of proprietary and the CC BY-ND 3.0 text in its LICENSE file. The actual service client is downloaded at runtime from the vendor's server.
Can I have a shared cache without a vendor account?
Yes. The NX_SELF_HOSTED_REMOTE_CACHE_SERVER variable switches the core to an HTTP client, and the protocol reduces to put and get on the /v1/cache/{hash} path with a bearerToken. You have to write or deploy the server yourself.
Nx or Turborepo?
If all you want is skipping unchanged tasks and a shared cache, Turborepo is smaller and leaves a smaller footprint. If you need generators, version migrations across major releases and target inference from tool configuration, Turborepo does not have those.
What happens when the service is unreachable?
Nx prints a warning that it failed to download the client and continues without writing to or reading from the remote cache. The run is not aborted, you only lose remote hits.