Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Configuring Persistent Dev Tasks in Turborepo

Development servers and watch-mode builds never exit, and that breaks the assumptions a task runner makes about ordinary tasks: they cannot be cached, nothing can depend on them "finishing", and their output needs to stay interactive. Turborepo models them as persistent tasks. Configured correctly, turbo dev starts every application's dev server alongside the watchers its libraries need, in one terminal, and a new developer can go from clone to running applications with two commands. Configured wrongly, it hangs waiting for a watcher to finish, refuses to run with a dependency error, or caches a dev server's non-existent output. This guide sets up persistent tasks, explains the rules Turborepo enforces, and covers turbo watch and interactive tasks.

Exact symptoms and error messages

Turborepo refuses to let a task depend on a persistent task, because the dependent would wait forever:

$ pnpm turbo run dev
  × Invalid task configuration
  ╰─▶ "@acme/ui#dev" is a persistent task, "@acme/web#dev" cannot depend on it

Without persistent: true, a watcher blocks the rest of the graph instead:

• Running dev in 4 packages
@acme/ui:dev: [watch] build started
@acme/ui:dev: [watch] build finished, watching for changes...
# ...and @acme/web:dev never starts, because it waits for ^dev to complete

And dev servers that need keyboard input do not receive it:

@acme/mobile:dev: › Press a │ open Android
@acme/mobile:dev: › Press i │ open iOS simulator
# key presses have no effect

Root cause analysis

A normal Turborepo task has a start and an end; dependsOn means "wait for that task to end". A persistent task never ends, so it cannot satisfy a dependency, and caching makes no sense for it. Turborepo therefore requires long-running tasks to be marked persistent: true and cache: false, and validates that nothing depends on them. The general task model is covered in Turborepo Pipeline Configuration.

Ordinary tasks versus persistent tasks Compares ordinary and persistent Turborepo tasks on whether they exit, can be cached, can be depended on and can depend on other tasks. ordinary (build, test) persistent (dev, watch) Exits yes runs until stopped Cacheable yes cache: false Can be a dependency yes never Can have dependencies yes yes, e.g. ^build Interactive input no with interactive: true
Persistent tasks may depend on others, but nothing may depend on a persistent task.

The design implies a pattern for libraries, and it is worth stating explicitly because it is the opposite of what most people write first: an application's dev server can depend on its libraries being built once (an ordinary task), and each library can run its own watcher in parallel as a separate persistent task. The application does not depend on the watchers — they simply run alongside.

Resolution and configuration patch

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "persistent": true
    },
    "dev:mobile": {
      "cache": false,
      "persistent": true,
      "interactive": true
    }
  }
}

With package scripts:

// packages/ui/package.json
{ "scripts": { "build": "tsup", "dev": "tsup --watch" } }

// apps/web/package.json
{ "scripts": { "build": "next build", "dev": "next dev" } }

Running pnpm turbo run dev now:

  1. Builds every library the apps depend on once (^build, cached as usual).
  2. Starts every package's dev script in parallel — tsup --watch in libraries, next dev in the app.
  3. Keeps them all running, streaming prefixed logs to one terminal (or the interactive UI).
Task graph for turbo run dev Library builds run first as ordinary cached tasks; then the app dev server and the library watchers start in parallel as persistent tasks with no dependencies on each other. @acme/ui#build ordinary, cached @acme/utils#build ordinary, cached @acme/web#dev persistent @acme/ui#dev persistent watcher @acme/utils#dev persistent watcher
The app depends on library builds, never on library watchers — all dev tasks then run side by side.

Start only one app and what it needs with a filter:

pnpm turbo run dev --filter=@acme/web...

The ... suffix includes the app's dependencies, so their watchers run too.

turbo watch as an alternative

Many libraries do not have a good watch mode, and running a watcher per library multiplies processes. Some watchers also rebuild on every save even when nothing that affects output changed, which triggers needless reloads in every dependent dev server. turbo watch inverts the approach: Turborepo watches the file system itself and re-runs the ordinary task graph for changed packages.

pnpm turbo watch build

When a file in packages/ui changes, turbo watch re-runs @acme/ui#build and then the builds that depend on it, using normal caching. Persistent tasks in the graph (such as the app's dev) are started once and left running. The two approaches combine well: turbo watch dev starts dev servers as persistent tasks while re-running library build tasks on change, so libraries need no watch scripts of their own.

The sequence below shows what happens when a library file changes under turbo watch dev:

turbo watch reacting to a library change A developer edits a ui source file; turbo watch detects it, re-runs the ui build and dependent builds, while the web dev server keeps running and hot-reloads the new output. Editor turbo watch @acme/ui#build @acme/web#dev save packages/ui/src/Button.tsx re-run build (cache miss) dist/ updated already running: no restart
Only ordinary tasks are re-run on change; persistent dev servers keep running and pick up the new build output.

Interactive tasks

Some dev servers read from stdin — Expo, Wrangler, test runners in watch mode. Mark them interactive: true (which requires cache: false), and use the terminal UI to focus a task and type into it:

{
  "ui": "tui",
  "tasks": {
    "dev:mobile": { "persistent": true, "cache": false, "interactive": true }
  }
}

In the TUI, select the task and press Enter to attach input. Stream mode ("ui": "stream") interleaves logs and cannot forward input.

Ports, environment and running many apps

Starting every application at once surfaces practical problems that single-app development never had. Each dev server needs its own port; hard-code them in each app's dev script (next dev --port 3001) or read them from a per-app .env.development, and document the mapping in the repository README so developers know where each app lives. Two apps that default to the same port will race, and one will fail or silently pick another port.

Environment variables behave differently for persistent tasks than for builds. Strict environment mode still applies — a dev server only sees declared or pass-through variables — but because dev tasks are not cached, the distinction between env and passThroughEnv matters less. Many teams declare development-only variables under the dev task's passThroughEnv, or rely on frameworks loading .env.development from disk, which strict mode does not filter.

Resource usage is the last concern. Nine dev servers and watchers can easily use several gigabytes of memory and saturate file watchers on Linux (ENOSPC: System limit for number of file watchers reached). Start only what you need with filters, raise fs.inotify.max_user_watches on Linux development machines if needed, and prefer turbo watch over per-library watchers to reduce the number of processes watching the same files.

Persistent tasks in CI and containers

Persistent tasks rarely belong in CI, but two cases come up. End-to-end tests often need a dev or preview server running while the test runner executes; model that with a test task that starts the server itself (for example, Playwright's webServer option) rather than making the test depend on a persistent task, which Turborepo forbids. And local development in containers — a docker compose setup running turbo dev — works as long as the container forwards signals to Turborepo (use init: true or an init process) so Ctrl+C and docker compose down stop every child process cleanly instead of leaving orphaned servers holding ports.

Worked example: a dev command that never started the app

A team's turbo dev started three library watchers and then sat indefinitely. Their configuration had "dev": { "dependsOn": ["^dev"], "cache": false } — each app waited for its libraries' dev tasks to finish, which never happens, and because persistent was not set, Turborepo did not know to reject the configuration. Changing the dependency to ^build, adding persistent: true, and letting library watchers run alongside made the app start in seconds. Later, the team removed the library watchers entirely in favour of turbo watch dev, cutting the number of long-running processes from nine to three.

Prevention and guardrails

  • Mark every long-running task persistent: true and cache: false.
  • Depend on ^build from dev tasks, never on ^dev.
  • Use filters with ... to start one app and what it needs.
  • Prefer turbo watch when libraries lack reliable watch modes.

Frequently Asked Questions

Why can a persistent task depend on others but not the reverse? A dependency must finish before the dependent starts. Persistent tasks never finish, so they can only be leaves of the "wait for" relationship, never prerequisites.

Does turbo dev use the remote cache? Dev tasks themselves are not cached, but the ^build tasks they depend on are, so the initial library builds are often cache hits.

How do I stop all dev processes cleanly? Press Ctrl+C once in the terminal running Turborepo. It forwards the signal to every task. Tasks that ignore signals may need their own shutdown handling.

Can I run a one-off setup step before dev servers start? Yes. Make it an ordinary task — database migrations, code generation — and add it to the dev task's dependsOn. It runs to completion, is cacheable if deterministic, and the dev servers start afterwards.

Why do logs from different dev servers interleave unreadably? In stream mode, every task writes to one output. Switch to the terminal UI with "ui": "tui" to view each task's log separately, or filter to fewer apps.

Related

Turborepo Pipeline Configuration