How Turborepo Builds Its Graph?
Aleksandr Ippatev

Aleksandr Ippatev @ippatev

Joined:
Jun 19, 2020

How Turborepo Builds Its Graph?

Publish Date: Sep 3 '25
3 1

How Turborepo Builds Its Graph

Turborepo creates its dependency graph by analyzing the package.json files within your monorepo. It looks for two key things:

  1. dependencies: What external npm packages and, crucially, what other local workspaces a package depends on.
  2. devDependencies: Similar to dependencies, these also define relationships between workspaces.

When you run turbo run build --graph, Turborepo:

  1. Discovers all workspaces.
  2. For each workspace, it reads its package.json.
  3. It draws a dependency edge from a package (e.g., web-app) to any other workspace listed in its dependencies or devDependencies (e.g., ui-components, utils).
  4. It uses this graph to determine the correct order of execution and what can be cached.

Why Lazy (Dynamic) Imports Are Ignored

Lazy loading, typically done with syntax like import('./some-module') or await require('./other-module'), is a runtime concept.

  • Turborepo operates at the build/package level. It runs tasks (like build, test, lint) on entire packages. It doesn't analyze the source code inside those packages to see how modules are imported at runtime.
  • Dynamic imports are resolved by the application bundler (e.g., Webpack, Vite, Rollup) during the application's build process, not by Turborepo's task runner.
  • The dependency graph Turborepo builds is a task-level graph, not a source-code-level graph.

A Practical Example

Imagine this monorepo structure:

my-turborepo/
├── apps/
│   └── next-app/
│       ├── package.json
│       └── pages/
│           └── index.js
└── packages/
    ├── ui/
    │   ├── Button.jsx
    │   └── package.json
    └── utils/
        ├── helpers.js
        └── package.json
Enter fullscreen mode Exit fullscreen mode

apps/next-app/package.json:

{
  "name": "next-app",
  "dependencies": {
    "ui": "workspace:*",
    "utils": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

apps/next-app/pages/index.js:

// Static import - Turborepo knows about this via the package.json
import { Button } from 'ui';

// Dynamic import - Invisible to Turborepo's graph
const DynamicComponent = dynamic(() => import('../components/HeavyComponent'));

function HomePage() {
  return (
    <div>
      <Button>Click Me</Button>
      <DynamicComponent />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What Turborepo Sees:
The graph will show next-app -> depends on -> ui and utils. It will ensure the build tasks for ui and utils run before the build task for next-app.

What Turborepo Does NOT See:

  • The dynamic import of HeavyComponent.
  • Any other dynamic import within the source code.

Implications and Best Practices

  1. Correct Caching: This behavior is correct and desired. If you change a dynamically imported module inside the next-app itself (like HeavyComponent.js), you don't want Turborepo to invalidate the cache for the entire ui or utils packages. You only want to rebuild next-app. Your application bundler (Next.js, Vite, etc.) handles the granular caching of those internal source files.

  2. Defining Dependencies Correctly: The takeaway is that you must declare all package-level dependencies in your package.json files. If next-app uses a component from the ui package, even if it's dynamically imported, the ui package must be listed as a dependency. Turborepo relies solely on this declaration.

In summary: Turborepo's graph is built from package.json declarations, not from scanning source code for import statements. Lazy loading is a runtime concern handled by your bundler and is invisible to Turborepo's task scheduling and caching mechanism.

Comments 1 total

  • Anshul
    AnshulJan 9, 2026

    This is what I was looking for !!

Add comment