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.
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:
- Builds every library the apps depend on once (
^build, cached as usual). - Starts every package's
devscript in parallel —tsup --watchin libraries,next devin the app. - Keeps them all running, streaming prefixed logs to one terminal (or the interactive UI).
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:
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: trueandcache: false. - Depend on
^buildfrom dev tasks, never on^dev. - Use filters with
...to start one app and what it needs. - Prefer
turbo watchwhen 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 explains task dependencies and caching.
- Using Package-Level turbo.json Overrides lets one package change its dev task.
- Using Internal Packages Without a Build Step removes the need for library watchers entirely.
- Running Workspace Scripts in Topological Order covers ordering without a task runner.