What continuous tasks are and why the classic dependsOn model broke
In Nx, dependsOn has always meant "finish this first." When a target lists a dependency, Nx waits for that dependency to complete successfully before starting the dependent. That works for builds, code generation and type checks, because they exit. It fails for dev servers, file watchers and local emulators, which are designed never to exit. If your serve target for a web app depends on an API's serve target, Nx starts the API and then waits forever, so the web app never starts.
Teams worked around this outside the task graph. Some opened one terminal per service. Some wrapped several commands in a concurrently-style npm script. Others used a Procfile with a process manager, or a shell script that backgrounded processes with &. Each of these works, but each hides the real dependency structure from Nx. The tool cannot see that the frontend needs the API, so it cannot start things in the right order, apply project filtering, or show the relationship in the graph. That knowledge ends up in a script someone has to maintain separately.
Continuous tasks solve this by marking a target as long-running. When you set continuous: true on a target such as serve, Nx stops treating it as something that must complete. Dependents can start once the continuous task has been launched, without waiting for it to exit. A minimal setup is a serve target with an executor or command plus "continuous": true.
This is a relatively recent capability. Check the official Nx documentation for your installed version to confirm the exact property name and how it behaves.
Backend and platform engineers get the most from this feature. If you run multi-service local stacks in a monorepo, such as APIs, workers, gateways and frontends, you can declare the stack's structure once and let Nx start it.
// apps/api/project.json (Nx parses project.json with comments allowed)
{
"name": "api",
"targets": {
"serve": {
// Long-running dev server: it never exits
"command": "node dist/apps/api/main.js",
// Tell Nx not to wait for this task to complete
"continuous": true
}
}
}
// apps/web/project.json
{
"name": "web",
"targets": {
"serve": {
"command": "vite --port 4200",
"continuous": true,
// Start api:serve first. Because it is continuous, web:serve
// starts once api:serve is launched instead of waiting forever.
"dependsOn": [{ "projects": ["api"], "target": "serve" }]
}
}
}
// Run both with one command: nx serve web
Two project.json files where web:serve depends on a continuous api:serve, so nx serve web starts the API and then the frontend without blocking. Confirm the continuous property in the docs for your installed Nx version.
How Nx orders continuous and discrete dependencies in the task graph
The task graph has two kinds of edges, and Nx treats them differently. An edge to a discrete task means "wait for this to exit successfully." An edge to a continuous task means "make sure this has been launched." Nx never waits for a continuous task to finish, because it never does.
Take a web app whose serve target depends on three things: a one-shot build of shared-ui, a one-shot codegen step that generates API client types, and the API's own continuous serve. The API's serve also depends on codegen.
web:serve [C]
├── shared-ui:build [D] must exit 0
├── api:codegen [D] must exit 0
└── api:serve [C] started, keeps running
└── api:codegen [D] (same task, runs once)
[D] = discrete: runs to completion
[C] = continuous: starts and stays up
The resulting startup order:
shared-ui:buildandapi:codegenrun in parallel. Each is cacheable and must succeed.- Once
api:codegenexits, Nx launchesapi:serve. - Once
shared-ui:buildhas also exited andapi:servehas been launched, Nx launchesweb:serve. Both servers then run side by side.
A failure in either discrete task stops the run before any server starts. This is the real value of the model: you never boot a frontend against stale generated types.
What the ordering does not cover
Nx guarantees start order relative to discrete tasks, and nothing beyond that. "Launched" does not mean "listening." Nx does not probe ports, poll health endpoints or parse log output. If web:serve proxies to the API, its first requests can still fail while the API is booting. Section 5 covers how to close that gap.
[D] discrete: runs, must exit 0 [C] continuous: launched, stays up
┌───────────────────┐ ┌───────────────────┐
│ shared-ui:build │ │ api:codegen │
│ [D] │ │ [D] │
└─────────┬─────────┘ └─────────┬─────────┘
│ exit 0 │ exit 0
│ ▼
│ ┌───────────────────┐
│ │ api:serve [C] │
│ │ launched ─────────┼──▶ keeps running
│ └─────────┬─────────┘
│ │ launched
│ │ (not yet listening)
└──────────────┬──────────────┘
▼
┌───────────────────┐
│ web:serve [C] │
│ launched ─────────┼──▶ keeps running
└───────────────────┘
Either [D] task fails ──▶ run stops, no server is launched.
Not checked by Nx: open ports, health endpoints, log output.
Nx waits for the discrete shared-ui:build and api:codegen tasks to exit successfully, then launches api:serve and web:serve, which keep running side by side; it does not check whether either server is ready to accept requests.
Implementing nx serve dependencies and watchers
Start with the runtime dependency. In the web project's configuration, give serve a dependsOn entry of api:serve, or the equivalent object form naming projects: ["api"] and target: "serve". The API's serve target must itself be marked "continuous": true, because that marker is what tells Nx not to wait for it to exit. The explicit form fits when the dependency is a fact about runtime: the web app proxies to that specific API. It stays correct even if the API is never an import-level dependency of web.
^serve serves a different purpose. It starts serve on every project that web depends on in the project graph and that defines the target. This suits a frontend composed of several locally served remotes. Avoid it when upstream libraries carry serve targets you don't want running, because the graph decides what starts, not you.
Watchers follow the same pattern. Give a shared library a watch target, such as its build in watch mode, and give a schema package a codegen-watch target. Mark both continuous and add them to web:serve's dependsOn. Serving the app then keeps the generated types and compiled output current.
Before adding these watchers, check how your bundler resolves the library. If TypeScript path aliases or package exports point at source files, the dev server already watches those files and a separate watcher only duplicates rebuilds. You still need a watcher for codegen, because no bundler regenerates outputs from a schema file.
Run nx serve web once. Then open nx graph and switch to the task view to confirm the continuous tasks hang off web:serve as intended.
{
"name": "web",
"targets": {
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"command": "vite",
"cwd": "apps/web"
},
"dependsOn": [
"api:serve",
{ "projects": ["shared-ui"], "target": "watch" },
{ "projects": ["schema"], "target": "codegen-watch" }
]
}
}
}
The web project's serve target depends on api:serve and on the library and codegen watchers. Each target listed must also set "continuous": true. Swap in "^serve" to start every upstream project's serve target.
Orchestrating several dev servers with run-many
Once a single app works, the next step is usually starting several frontends together, for example nx run-many -t serve -p web admin. Both apps may declare the same API as a continuous dependency. The question that matters is whether the API starts once or once per dependent. Nx identifies tasks by project and target, so a shared dependency should collapse into a single node. Do not assume this holds for your workspace, though. Configurations, target names or per-project overrides can produce distinct task IDs that each spawn a process. Check the task view in nx graph for a single API node with two incoming edges. Then list running processes or bound ports to confirm only one server is actually listening.
Continuous tasks do not allocate ports. If two apps default to the same port, the second one fails to bind or quietly picks another port, depending on the tool. Pin ports explicitly in each project's configuration. Port collisions also happen when two developers' stacks share a remote dev host. In that case, derive ports from an environment variable per user. The same applies to shared state such as a local database, a cache directory or a file-based queue, which two simultaneous API instances can corrupt or contend over.
Several long-running processes writing to one terminal produce interleaved output that is hard to read. Check nx run-many --help for the terminal UI and output-style options your Nx version supports. Interactive terminals and CI behave differently: a TUI typically switches off when no TTY is attached, so test both paths.
#!/usr/bin/env bash
# Verify that a shared continuous dependency (the API) starts once, not once per app.
set -euo pipefail
# 1. Inspect the task graph before running anything.
# Look for a single api:serve node with edges from web:serve and admin:serve.
nx run-many -t serve -p web admin --graph
# 2. Give each developer on a shared host their own port range.
# Each project's serve config should read these variables instead of a
# hard-coded default. Nx does not allocate ports for continuous tasks.
PORT_BASE=$(( 4000 + ($(id -u) % 100) * 10 ))
export API_PORT=$(( PORT_BASE + 0 ))
export WEB_PORT=$(( PORT_BASE + 1 ))
export ADMIN_PORT=$(( PORT_BASE + 2 ))
# 3. Start both frontends. Their shared API dependency starts with them.
nx run-many -t serve -p web admin &
NX_PID=$!
sleep 20 # give the servers time to bind
# 4. Confirm what actually happened, not what the graph promised.
# Exactly one listener should be on the API port.
ss -ltnp | grep -E ":(${API_PORT}|${WEB_PORT}|${ADMIN_PORT})\b" || true
pgrep -af 'serve' || true
# 5. Check which output and terminal UI options your Nx version supports.
# A TUI usually switches off when no TTY is attached, as in CI.
nx run-many --help | grep -iE 'tui|output-style' || true
kill "$NX_PID"
A shell session that checks the task graph, gives each user on a shared host their own ports, starts two frontends, and then confirms from bound ports and running processes that the shared API is running only once.
Readiness, caching and shutdown: the parts Nx does not handle for you
Readiness
Nx treats a continuous dependency as satisfied once its process has started. It does not wait for the process to accept connections. A frontend dev server can therefore come up before the API it proxies to has bound its port, and the first requests fail. The most reliable fix is in the client: retry with backoff, so a slow boot looks like a short delay rather than an error. Dev proxies should also tolerate a backend that is still starting and return a retryable error instead of crashing. Add an explicit wait-for-port step only where something cannot retry, such as an e2e runner that fires requests immediately.
Caching
A serve or watch task produces no artifact. Its value is the running process, and a cache cannot replay that. Leave cache off for these targets and do not declare outputs. When teams copy target settings from build, two things can go wrong. A cache hit can replay logs and exit without starting anything. Declared outputs can also cause Nx to restore or overwrite files that a watcher is writing to at the same moment.
Shutdown
When the top-level command exits or you press Ctrl+C, every child should stop. If a port is still taken afterward, lsof -i :4200 or ss -ltnp shows which process holds it. Tools that start their own subprocesses, such as docker compose or some language servers, may not pass the signal on to them. Check that their children actually exit.
In CI, give e2e targets that depend on a served app a pipeline-level timeout, and add a final step that confirms the ports are free.
When to use continuous tasks, when to avoid them, and the alternatives
Continuous tasks are a good fit when your local stack has real runtime dependencies between workspace projects, such as a web app that cannot do anything useful without the API and a queue worker, and the team wants one entry point. The dependency is declared once, next to the project that has it, so the graph stays accurate as projects are added and removed.
Limit their use in three situations:
- The topology is too heavy to hide. When a single command boots eight processes, a new engineer has no idea what is running or why it failed. Keep the graph shallow, or document it in the project's README.
- The edit loop is tight. Over-declared
dependsOnentries start unrelated services every time someone wants to iterate on one frontend. Add a lighter target, such asserve-standalone, that skips dependencies and points at a shared or mocked backend. - The lifecycles don't match. Databases and brokers started as containers often outlive the Nx process, and their state should persist across restarts anyway. Manage them outside the task graph and let Nx serve only workspace code.
| Approach | Graph awareness | Startup ordering | Readiness handling | Shutdown behavior | Output readability | Onboarding visibility |
|---|---|---|---|---|---|---|
| Nx continuous tasks | Full workspace graph | Dependencies launched first | None built in | Nx stops tasks; child processes may leak | Per-task in Nx output | Declared in project config |
| Concurrently-style scripts | None | All at once | External wait scripts | Optional kill-all on exit | Interleaved, prefixed | One script line |
| Procfile process manager | None | All at once | None | Manager stops all on signal | Interleaved, prefixed | Readable process list |
| Separate terminals | None | Manual | Human judgment | Manual, easy to orphan | Clean per terminal | Tribal knowledge |
| Standalone docker compose | Containers only | depends_on |
Health check conditions | Explicit down |
Per-service logs | Compose file |
Impact on cost and engineering time, plus a migration checklist
The savings are mostly maintenance and coordination. Each wrapper script you delete is one less place where startup order is written down. Each concurrently invocation you remove is one less file that falls out of sync with the project graph. New engineers run one command instead of reading a README that lists terminals in order. When dependency relationships live in dependsOn, nx graph shows them, and they get reviewed like any other config change.
The costs are real too. If you over-declare stacks, every serve starts services nobody needed, which costs local CPU and memory. Readiness races move from "start terminal B after A says ready" to intermittent first-request failures. A continuous task that slips into a CI target can hang the pipeline until it times out. Config keys also change between Nx releases, so snippets copied from elsewhere may silently do nothing.
Illustrative estimate (assumed numbers, not a benchmark). Monthly hours saved = (wrapper edits × minutes per edit + onboarding sessions × minutes saved) ÷ 60. Assume 6 edits at 20 minutes each and 3 onboardings at 40 minutes saved each: (120 + 120) ÷ 60 = 4 hours. Subtract the time you spend debugging readiness issues during the same period.
Migration checklist
- List every long-running process in your scripts or Procfile.
- Mark those targets continuous.
- Express their start relationships in
dependsOn. - Remove
cacheandoutputsfrom those targets. - Delete the wrapper scripts and the Procfile entries.
- Check the edges in the project graph.
- Confirm the ports are free before you start. Then stop the stack with Ctrl+C and confirm the ports are free again.
- Check the behavior against your installed Nx version's documentation.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx continuous tasks, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.