Your plugin's createNodes function runs every time Nx computes the project graph, which happens before any task is restored from cache. If it does expensive work for every matching file, that cost is paid on every command (nx build, nx graph, even editor integrations), and it is paid by every developer and every CI machine. A plugin that recomputes everything from scratch is a common cause of slow graph creation, and the cost is magnified on Windows, where filesystem and process-spawn operations are significantly more expensive than on Linux or macOS.
The patterns below are the ones the first-party Nx plugins use to keep graph creation fast. They assume you're already familiar with the project graph plugin API and have written a basic tooling plugin.
Set up shared state once per batch
Section titled “Set up shared state once per batch”createNodes hands you all matching files in a single call, which lets you set up shared state once and amortize expensive work across every project. Every other pattern here depends on processing the batch together rather than file by file.
Wrap your per-file logic with createNodesFromFiles, which fans the files out and processes them in parallel while keeping the returned results in a deterministic order:
import { CreateNodes, CreateNodesContext, createNodesFromFiles,} from '@nx/devkit';
const configGlob = '**/my-tool.config.{js,ts}';
export const createNodes: CreateNodes<MyPluginOptions> = [ configGlob, async (configFiles, options, context) => { // Set up shared state here, once for the whole batch (see below). return await createNodesFromFiles( (configFile, options, context, idx) => createNodesInternal(configFile, options, context, idx), configFiles, options, context ); },];Cache results to disk
Section titled “Cache results to disk”Most of a plugin's cost is recomputing target configuration that hasn't changed. Cache the result of processing each config file on disk, keyed by a hash of everything that can affect the output. On the next run, unchanged projects are read straight from the cache instead of being reprocessed.
The hash must include every input that affects the result: the project's files, the plugin options, and any external files the config references (for example, a lockfile or a shared base config). calculateHashesForCreateNodes batches the workspace-context hashing for all project roots into a single call, much faster than hashing each root individually.
import { createNodesFromFiles } from '@nx/devkit';import { PluginCache, calculateHashesForCreateNodes,} from '@nx/devkit/internal';import { hashObject } from 'nx/src/devkit-internals';import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';import { dirname, join } from 'node:path';
export const createNodes: CreateNodes<MyPluginOptions> = [ configGlob, async (configFiles, options, context) => { // One cache file per unique set of options so different plugin // configurations don't collide. const optionsHash = hashObject(options); const cachePath = join( workspaceDataDirectory, `my-plugin-${optionsHash}.hash` ); const targetsCache = new PluginCache<MyTargets>(cachePath);
const projectRoots = configFiles.map((f) => dirname(f)); const hashes = await calculateHashesForCreateNodes( projectRoots, options, context );
try { return await createNodesFromFiles( async (configFile, options, context, idx) => { const hash = hashes[idx];
if (!targetsCache.has(hash)) { // Only the expensive work runs on a cache miss. targetsCache.set( hash, await buildTargets(configFile, options, context) ); }
return { projects: { [projectRoots[idx]]: { targets: targetsCache.get(hash) }, }, }; }, configFiles, options, context ); } finally { // Always persist, even if one file throws, so the work already // done isn't lost. targetsCache.writeToDisk(); } },];A few things to get right:
Write the cache in a
finallyblock so a failure processing one file doesn't discard the entries computed for the others.Include referenced files in the hash. If your config
extendsa base file or your targets depend on a lockfile, pass those paths through theadditionalGlobsargument ofcalculateHashesForCreateNodesso a change to them invalidates the cache:const hashes = await calculateHashesForCreateNodes(projectRoots,options,context,projectRoots.map(() => [lockFileName, ...referencedConfigFiles]));Respect
NX_CACHE_PROJECT_GRAPH=false.PluginCachealready does this: it returns an empty cache when the variable is set, which is what makes the development overrides below work.
Hoist shared work out of the per-file loop
Section titled “Hoist shared work out of the per-file loop”Even with a disk cache, the first run (and any run after a change) processes files. Anything that's identical across files should be computed once per batch, not once per file. Set up in-memory caches in the createNodes body and pass them into your per-file function:
async (configFiles, options, context) => { // Detected once for the whole workspace, not per project. const packageManager = detectPackageManager(context.workspaceRoot);
// Shared across files: many projects extend the same base tsconfig or // use the same preset, so read and resolve each one only once. const tsconfigCache = new Map<string, RawTsconfig>(); const presetCache: Record<string, unknown> = {};
return await createNodesFromFiles( (configFile, options, context, idx) => buildTargets(configFile, options, context, { packageManager, tsconfigCache, presetCache, }), configFiles, options, context );};Common things worth hoisting: package-manager detection, lockfile name resolution, shared base configs, and tool presets. The @nx/jest plugin, for example, caches resolved presets and the tsconfig extends chain this way because most projects in a workspace share them.
Load configuration files in parallel
Section titled “Load configuration files in parallel”When you must read or evaluate config files, do it concurrently with Promise.all rather than in a sequential loop. Reading config off disk is I/O-bound, and transpiling and evaluating TypeScript configs is CPU-bound. Both parallelize well:
const loadedConfigs = await Promise.all( configFiles.map((configFile) => loadConfigFile(join(context.workspaceRoot, configFile)) ));This matters most on Windows, where each readFileSync/readdirSync call carries more overhead, so collapsing serial I/O into parallel batches has an outsized effect.
Keep your file glob narrow
Section titled “Keep your file glob narrow”The first element of the createNodes tuple is a glob Nx matches against the whole workspace. A broad pattern like **/*.json forces Nx to consider far more files and calls your function with files you'll only discard. Match the most specific filename you can:
// Prefer thisconst configGlob = '**/vite.config.{js,ts,mjs,mts,cjs,cts}';
// Over a catch-all you have to filter down yourselfconst configGlob = '**/*.config.*';If you can only decide whether a file is relevant after looking at its siblings (for example, requiring a package.json or project.json next to it), do that check early and return {} before doing any expensive work. A precise glob is still cheaper than a broad glob plus a filter.
Keep output deterministic
Section titled “Keep output deterministic”Nx hashes the graph node your plugin produces to decide whether cached task results can be reused. If your function returns different output across runs or machines, because of array ordering, absolute paths, or environment values, Nx computes a different hash and treats unchanged code as a cache miss, defeating both your plugin cache and task caching downstream.
Sort arrays (targets, inputs, outputs, dependsOn) explicitly before returning, and avoid leaking process.env values, absolute paths, timestamps, or random IDs into target configuration. Prefer workspace-relative tokens like {workspaceRoot} and {projectRoot}. See Inferred Tasks for the full set of determinism rules.
Avoid per-file process spawning and heavy top-level imports
Section titled “Avoid per-file process spawning and heavy top-level imports”Two patterns quietly dominate graph-creation time:
- Spawning a child process per file. If your plugin shells out to a tool to read configuration, the process-startup cost is paid for every project and is especially expensive on Windows. Batch the work into a single invocation where possible, or read the configuration directly instead of shelling out.
- Heavy imports at module load. Everything imported at the top of your plugin entry point is loaded every time Nx loads the plugin, even on a full cache hit. Keep top-level imports light and
await import(...)heavy or rarely-used dependencies inside the code path that actually needs them.
Develop and debug your plugin
Section titled “Develop and debug your plugin”While iterating, disable the caching layers that would otherwise hide your changes:
# The daemon caches your plugin code, so changes won't take effect until it restarts.NX_DAEMON=false nx graph
# Bypass the project graph cache so your createNodes logic always re-runs.NX_CACHE_PROJECT_GRAPH=false nx graphTo find where graph-creation time is actually going, turn on performance logging. Nx prints the duration of each internal step, including project-graph construction:
NX_PERF_LOGGING=true NX_DAEMON=false nx graphIf a user reports slow graph creation, ask them for this output along with nx report. The per-step timings make it clear whether the cost is in your plugin (createNodes) versus elsewhere, and nx report confirms the Nx and plugin versions in play.
Related documentation
Section titled “Related documentation”- Extending the Project Graph - The full project graph plugin API
- Integrate a New Tool with a Tooling Plugin - End-to-end tutorial for building a plugin
- CreateNodes API Compatibility - Supporting multiple Nx versions
- Inferred Tasks - How Nx builds the graph from plugins
- CreateNodesV2 API Reference - Detailed API documentation