What a continuous task is and why plain dependsOn failed
A continuous task is an Nx target configured with "continuous": true. That flag tells Nx the task is not expected to exit. Nx starts it and keeps it running, and it does not wait for the task to complete before moving on. Dev servers, file watchers, local queue consumers, and tsc --watch processes all fit this description. Their useful state is "running," not "done."
Before this flag existed, dependsOn had a single meaning: run the dependency, wait for it to finish successfully, then run the dependent task. That works for build depending on ^build, because a build terminates. It breaks when the dependency is a server. If serve for a frontend declared a dependency on the API's serve target, Nx would start the API and then wait indefinitely for it to exit. The frontend never started.
Teams worked around this outside the task graph. Some used a root package.json script that ran several nx serve commands in parallel through a process-multiplexing tool. Others wrote a README section telling new hires to open three terminals and start services in a specific order. Both approaches lost what Nx already knew about project relationships. Each one duplicated the dependency graph by hand, and that copy drifted as services were added or renamed.
Backend and platform engineers feel this most. Their monorepos often run an API, a worker, a frontend, and a code generator together. Continuous tasks let that topology live in project configuration instead of in tribal knowledge.
Availability and exact semantics depend on your installed Nx version. Confirm the behavior described here against the documentation for the version in your lockfile.
How dependsOn behaves when the dependency is continuous
For a normal dependency, dependsOn means "run this to completion, then start me." That rule cannot work for a target that never exits, which is why the old pattern hung or forced people into wrapper scripts. When a target is marked continuous, Nx changes the meaning of the edge: it starts the dependency, does not wait for it to exit, and then starts the dependent task so both run at the same time.
Take nx serve web, where web:serve lists two dependencies in dependsOn: api:serve and a codegen watcher that regenerates typed API clients when the schema changes. Both are continuous. Nx launches the API server and the watcher, then launches the web dev server alongside them. All three stay up, and stopping the command stops all of them. The dependency edge only controls start order. It does not tell you whether the API is accepting connections, which section 4 covers.
The more useful property shows up when several dependents share a dependency. Run serve for two frontends, web and admin, that both depend on api:serve. You want one API process on one port, not two processes fighting over the same port. Nx builds a single task graph for the invocation, so api:serve should appear as one node with two incoming edges and should be started once.
Don't assume this; check it. Run nx graph and switch to the task view, or pass --graph to the run command. Confirm that the shared API and watcher each appear as a single task node with multiple dependents before you rely on that behavior in scripts or CI.
nx run-many -t serve --projects=web,admin (one invocation = one task graph)
A -----> B means A dependsOn B (controls start order only)
+-------------+ +----------------------+ +-------------+
| web:serve |----->| api:serve |<-----| admin:serve |
| dev server | | continuous, 1 node | | dev server |
| | +----------------------+ | |
| | | |
| | +----------------------+ | |
| |----->| api:codegen |<-----| |
| | | continuous, 1 node | | |
+-------------+ +----------------------+ +-------------+
start order (continuous edges never wait for the dependency to exit):
t0 start api:serve + api:codegen -----------------------> running
t1 start web:serve + admin:serve (t0 still running) ----> running
t2 all four run side by side; stopping the command stops all four
edge != readiness: api:serve may not accept connections yet at t1
verify, don't guess:
nx graph (task view) or nx run-many -t serve --projects=web,admin --graph
expect: api:serve and api:codegen each = 1 node with 2 incoming edges
Two frontends depending on the same continuous API server and codegen watcher: Nx starts each shared dependency once and runs it alongside the dependents without waiting for it to exit. The shared nodes are expected to appear once in the task graph, which you should confirm with nx graph or --graph.
Configuring an API server, a frontend, and a watcher
Assume three projects: api, web, and a shared contracts library whose types are generated from a schema. The example configuration that follows defines four targets across them.
The API server
On api, the serve target uses whichever executor or command your stack already uses to start the server. Setting "continuous": true tells Nx the process is not expected to exit. Nx starts it and treats it as running rather than waiting for completion. Do not mark this target cacheable: a server produces no outputs worth restoring.
The frontend
On web, serve is also continuous. Its dependsOn names the API target in project-qualified form, either the object form { "projects": ["api"], "target": "serve" } or the string shorthand api:serve if your Nx version accepts it. Use the qualified form because a bare "serve" would point at web's own target, and "^serve" would fan out to every dependency that has one.
The watcher
contracts gets two targets:
codegen: a one-shot, cacheable target with declaredinputs(the schema) andoutputs(the generated directory). On a cache hit, Nx restores the files instead of regenerating them, which shortens startup.codegen-watch: continuous, running the generator in watch mode. ItdependsOncodegen, so the watcher starts from a warm tree.
web:serve lists contracts:codegen-watch as a second dependency. Use options for per-target arguments such as ports. Do not hardcode those values inside the command string, because they should stay overridable from the CLI.
Check inferred targets before overriding
Before writing any of this, run nx show project api and nx show project web. Inference plugins may already define serve or dev with continuous set, so your override may be redundant or may silently change behavior.
// libs/contracts/project.json (targets excerpt; Nx accepts comments here)
"codegen": {
// One-shot and cacheable: a cache hit restores src/generated without rerunning
"command": "node tools/codegen.js schema/api.graphql src/generated",
"cache": true,
"inputs": ["{projectRoot}/schema/**/*"],
"outputs": ["{projectRoot}/src/generated"]
},
"codegen-watch": {
// Long-running watcher that starts only after codegen has finished
"continuous": true,
"command": "node tools/codegen.js schema/api.graphql src/generated --watch",
"dependsOn": ["codegen"]
}
// apps/api/project.json
"serve": {
// Never exits, so Nx starts it and does not wait for it. No cache.
"continuous": true,
"executor": "nx:run-commands",
"options": { "command": "node src/main.js", "args": "--port=3333" }
}
// apps/web/project.json
"serve": {
"continuous": true,
"executor": "nx:run-commands",
// The port goes in options so it can be overridden from the CLI
"options": { "command": "vite", "args": "--port=4200" },
"dependsOn": [
// Project-qualified: a bare "serve" would mean web:serve
{ "projects": ["api"], "target": "serve" },
{ "projects": ["contracts"], "target": "codegen-watch" }
]
}
Target excerpts from three project.json files: web:serve depends on a continuous api:serve and a continuous contracts:codegen-watch, which builds on a cacheable one-shot codegen target.
Startup ordering is not readiness
When web:serve depends on a continuous api:serve, Nx starts the API process and then moves on to the dependent task. Do not assume it waits for the API to bind its port, finish migrations, or warm a cache. A Node server can take a few seconds to compile and connect to a database, while a Vite or webpack dev server may be up almost immediately. The ordering holds, but nothing guarantees the API is ready.
The symptoms are intermittent. The first page load shows a proxy error such as ECONNREFUSED, then works on refresh. An e2e suite fails its first spec on a cold CI runner and passes on retry. A watcher that regenerates types runs after the consumer has already read stale output. These failures depend on timing, so they appear on slower machines and disappear when you try to reproduce them locally.
You can handle this at three layers:
- Make the dev proxy tolerant. Configure it to return a clear 502 or retry instead of crashing or caching the failure. A backend that comes up late should cost the developer one reload.
- Retry on the client. Bounded retries with backoff on idempotent startup calls, such as config or session fetches, cover the gap.
- Add an explicit wait in the dependent target. Run a small script that polls the health endpoint until it gets a 200 or hits a timeout, then run the main command. Use the timeout to fail the target with a clear error. This is the most reliable option for test targets.
Readiness handling may change between Nx releases, so check how your installed version behaves. Don't rely on what you remember from an older version or on general claims like the ones above.
// tools/wait-for-ready.mjs
// Polls a health endpoint until it returns 200 or a deadline passes.
// Usage in a dependent target's command (Node 18+ for global fetch):
// node tools/wait-for-ready.mjs && <main command>
const url = process.argv[2] ?? '';
const timeoutMs = Number(process.env.READY_TIMEOUT_MS ?? 60000);
const intervalMs = 500;
const deadline = Date.now() + timeoutMs;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (Date.now() < deadline) {
try {
// Abort each probe after 2s so a hung server doesn't stall the loop.
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
if (res.status === 200) {
console.log(`ready: ${url}`);
process.exit(0);
}
// Port is open but the app isn't healthy yet (e.g. migrations running).
} catch {
// ECONNREFUSED or timeout: the process has started but isn't listening yet.
}
await sleep(intervalMs);
}
// Fail loudly so the target errors with a clear cause instead of a flaky test.
console.error(`not ready after ${timeoutMs}ms: ${url}`);
process.exit(1);
A small Node script that a dependent target can run before its main command so that the command starts only after the continuous API task's health endpoint returns 200, and fails with a clear error after a timeout.
Using continuous tasks for e2e and integration tests in CI
When an e2e or integration target lists a continuous serve target in dependsOn, Nx starts the server, runs the test target alongside it, and is expected to stop the server once the test target finishes. You do not need a separate script that backgrounds the server, waits, and kills it. Still, confirm that teardown actually happens on your CI runners, because several things can break it.
Why teardown matters in CI
- Timeouts. If the server survives the test, the job can hang until the CI provider's timeout kills it. You lose the runner time and get a vague failure.
- Orphaned children. Nx can only signal the process it started. A wrapper script that launches a JVM, or a Python server that forks workers, may leave grandchildren running after the parent exits. They keep ports bound, and the next job on a reused runner fails with "address already in use."
- Signal handling. Shells and platforms forward signals differently. A server started through
sh -c, or on Windows runners, may never receive the termination signal. - Interleaved logs. Server and test output share one stream. Prefix lines per service, or write each service's logs to its own CI artifact, so you can tell which process failed.
Caching and affected
Continuous tasks are never cached, so the server always starts fresh. Its one-shot dependencies are cached normally. When nx affected selects the e2e target, a server whose build inputs are unchanged gets that build from cache, and only the startup cost remains.
{
"name": "api-e2e",
"targets": {
"e2e": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
{ "projects": ["api"], "target": "serve" }
],
"options": {
"command": "npx wait-on tcp:localhost:8080 && npx jest --config apps/api-e2e/jest.config.ts"
}
}
}
}
// apps/api/project.json (separate file, shown here for reference)
{
"name": "api",
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"outputs": ["{workspaceRoot}/dist/apps/api"],
"options": { "command": "./gradlew -p apps/api bootJar" }
},
"serve": {
"continuous": true,
"dependsOn": ["build"],
"executor": "nx:run-commands",
"options": {
"command": "exec java -jar dist/apps/api/api.jar --server.port=8080"
}
}
}
}
Two project.json files: e2e depends on the continuous api:serve target, which Nx starts and should stop after the tests. The cached build runs first, and exec replaces the shell so the JVM receives the stop signal itself.
When to avoid continuous dependencies and what to use instead
Wiring a dependency as continuous makes it part of the caller's contract, and that has costs. If a frontend's serve depends on the API's serve, every frontend developer now boots the backend along with its databases, required environment variables, and memory footprint, whether or not they touch it. A missing secret or a stopped database container then breaks frontend work for reasons the developer cannot see from the task they ran.
Resource limits matter too. Each dev server and watcher holds memory and file handles, and on Linux, recursive watchers can exhaust the inotify watch limit long before CPU becomes the problem. A graph that fans out into many long-running processes may need a lower --parallel value, or fewer continuous edges.
In CI, every live server is a source of variance: port collisions, slow cold starts, and nondeterministic hot reloads. Prefer serving built output wherever the test doesn't need a watcher.
| Approach | Setup effort | Determinism | Local resource cost | Best fit |
|---|---|---|---|---|
| Nx continuous tasks | Low inside an Nx workspace | Medium | Grows with graph depth | Dev, and CI when servers are cheap |
| Concurrently-style npm scripts | Low | Low; no graph or readiness model | Fixed, explicit | Small repos, dev only |
| docker-compose for dev | Medium | High for infra versions | High | Databases and queues in dev |
| Mocks or stub servers | Medium; contracts can drift | High | Low | Frontend dev, unit and component tests |
| Shared remote dev environment | High to run and maintain | Low; shared state | Near zero locally | Teams with heavy backends |
| Prebuilt artifacts served statically | Low once builds are cached | High | Low | CI e2e |
A useful default is to make a dependency continuous only when the caller cannot do meaningful work without it, and to use stubs or built artifacts everywhere else.
Migration path and impact on cost and engineering time
Migrating one target at a time
Start with the most common local workflow, usually a frontend serve that needs an API. Replace the concurrently script or dev-only compose wrapper with a continuous serve target on the API and a dependsOn entry on the frontend. Leave the old script in place until the team has used the new path for a while, then delete it. Repeat for watchers and e2e targets. Each continuous target must still run on its own (nx run api:serve). When something misbehaves, you want to attach a debugger to one process, not untangle a graph.
Where time is saved
- One command replaces a "start these four things in this order" onboarding page.
- Fewer ordering mistakes, because the graph encodes the order and people no longer have to remember it.
- Build prerequisites come from cache instead of being rebuilt on every start.
Where cost is added
- Readiness checks and port-wait logic must be written and maintained.
- Cleanup handling is needed for orphaned processes and held ports after a crash.
- Laptops and CI runners need more memory because more processes run at once.
- You are coupled to Nx and plugin behavior, so upgrades need testing.
Illustrative estimate (assumed numbers, not a benchmark): monthly hours saved = engineers × startup mistakes per week × minutes lost per mistake × 4 ÷ 60. Assume 20 engineers, 1 mistake per week each, and 15 minutes lost per mistake: 20 × 1 × 15 × 4 ÷ 60 = 20 hours per month. Subtract the hours you spend maintaining the readiness and cleanup logic before deciding the migration pays off.
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.