What continuous tasks are and why dependsOn alone was not enough
Nx models work as a graph of tasks. A target such as build or test can declare dependsOn entries, and Nx runs those dependencies first, waits for each to exit successfully, then starts the dependent task. That rule is correct for discrete work like compiling a shared library. A compiler finishes and produces outputs, and the next step consumes them.
A dev server or an API running in watch mode never finishes. If a frontend's serve target listed the API's serve target under dependsOn, Nx would start the API and wait for it to exit, which would never happen. The frontend would not start. The graph had no way to say "start this and keep it running alongside me."
A continuous task is a target marked as long-running. When another task depends on it, Nx starts it and then proceeds without waiting for it to exit. The dependency stays alive for as long as the task that needs it.
Before this, teams worked around the gap in two ways:
- Separate terminals. Engineers started the API in one terminal and the web app in another. They had to remember the order and stop each process by hand.
- External process runners. Teams added wrapper scripts or parallel-process tools. These tools sat outside the task graph, so they did not know about project dependencies or affected projects.
The people who benefit most are backend and platform engineers who maintain workspaces where a frontend needs one or more local APIs running. With continuous tasks, a single serve command brings up the whole local stack.
Configuration and behavior can differ between Nx releases, and older workspaces may not support continuous tasks. Check the documentation for the Nx version installed in your workspace.
How Nx orchestrates continuous and discrete tasks in one pipeline
When Nx builds a task graph, every target is either discrete or continuous. A discrete target, such as build or a code generation step, is expected to exit. Nx waits for it to finish and checks its exit code before starting anything that depends on it. A continuous target, marked with "continuous": true in its target configuration, is expected to keep running. Nx launches it, keeps the process alive, and lets dependents start without waiting for it to exit.
Consider a web app's serve target that depends on the API's serve, which in turn depends on a discrete codegen target in a shared library. Nx runs codegen first and waits for it to exit successfully. It then launches the API server, and once that process is spawned, launches the web dev server. If codegen fails, neither server starts. Running nx graph and viewing task dependencies shows this chain. Continuous and discrete nodes appear in the same graph because they share one scheduler.
Nx guarantees the following:
- Discrete dependencies complete before their dependents begin.
- Continuous dependencies are started before their dependents.
Nx does not guarantee that a continuous dependency is ready. It knows a process exists. It does not know whether that process has bound its port, finished migrations, or can answer HTTP requests. Process start and readiness are separate events, and only the first one is visible to Nx. The web app may therefore send its first requests before the API is listening.
nx run web:serve
|
v
+---------------------------+
| shared-lib:codegen | DISCRETE
| runs to completion, exits | Nx waits for exit code 0
+---------------------------+ (non-zero exit: nothing below starts)
|
| exit 0
v
+---------------------------+
| api:serve | CONTINUOUS ("continuous": true)
| process spawned, kept up | Nx does not wait for it to exit
+---------------------------+
|
| spawned (not "ready")
v
+---------------------------+
| web:serve | CONTINUOUS
| dev server spawned | may call the API right away
+---------------------------+
api:serve lifecycle:
spawned -------> port bound -------> migrations done -------> serving HTTP
^ ^
| |
visible to Nx invisible to Nx
(start is guaranteed) (readiness is not guaranteed)
Nx waits for the discrete codegen task to exit successfully, then starts api:serve and web:serve as soon as each upstream process is spawned, so web:serve can send requests before the API is ready.
Implementing it: a web app, an API, and a shared library
Assume three projects: web (the frontend), api (an HTTP service), and shared (a library both import). Wire them in three steps.
- Mark the API as long-running. In
api's project configuration, setcontinuous: trueon itsservetarget. Without this flag, Nx waits for the API to exit before starting anything that depends on it, and a server never exits. - Make the frontend depend on it. Add an entry to
web'sserve.dependsOnthat names projectapiand targetserve. Nx then starts the API whenever someone serves the web app. - Build the library first. Give
api'sservetarget adependsOnon^build. Becausesharedis a dependency ofapi, its discretebuildruns and must finish before the API process starts.
The developer then runs one command: nx serve web. Nx builds shared first, then starts api, then starts web without waiting for the API to become ready. Output from both servers appears in one terminal, labelled by task. Expect the frontend's "ready" line to appear before or alongside the API's startup logs.
You can declare these dependencies per project or once in targetDefaults in nx.json:
| Concern | Per-project dependsOn |
targetDefaults |
|---|---|---|
| Scope | One project's target | Every project with that target name |
| Best for | Specific cross-project links | Uniform rules such as ^build |
| Risk | Repeated config across projects | Unneeded servers start for some projects |
Keep ^build in targetDefaults, because every project needs its dependencies built. Declare the api:serve dependency in each project that needs it. If only two of five frontends call this API, a shared default would start the API for the other three. That wastes ports and memory, and it hides which apps actually need the API.
// nx.json: uniform rule, every build waits for its dependencies' builds
{
"targetDefaults": {
"build": { "dependsOn": ["^build"] }
}
}
// apps/api/project.json: long-running server, built libraries first
{
"name": "api",
"targets": {
"serve": {
"continuous": true,
"dependsOn": ["^build"],
"command": "node apps/api/src/main.js"
}
}
}
// apps/web/project.json: only this frontend pulls in the API
{
"name": "web",
"targets": {
"serve": {
"continuous": true,
"dependsOn": [{ "projects": ["api"], "target": "serve" }],
"command": "vite --config apps/web/vite.config.ts"
}
}
}
// Run from the workspace root:
// nx serve web
// Order: shared:build completes, then api:serve starts, then web:serve
// starts without waiting for the API to be ready.
Three configuration files, shown together with comments marking each file. The uniform ^build rule sits in targetDefaults, api:serve is marked continuous, and only web declares a dependency on the API, so nx serve web builds shared and then runs both servers.
Readiness, ports, and environment wiring are still your job
Nx launching the API process is not the same as the API being ready. Treat a continuous dependency as started, not as listening. The frontend's dev server may come up first and send requests to a port that nothing is bound to yet. Check your installed Nx version's documentation for the exact startup semantics, and do not assume Nx waits for a health check.
You can absorb that gap in one of three places:
- The client. A short retry with backoff on the first API calls removes the race without adding tooling. This works best for data fetches the user can wait a second for.
- The dev proxy. If the frontend dev server proxies
/apito the backend, configure it to retry or return a clear 502 instead of crashing. Then a cold backend shows up as a recoverable error. - A wait-for-port step. Add a small step before the frontend makes its first requests that polls the API's port or health endpoint. This is the most explicit option, and it keeps the readiness logic out of application code.
Ports need the same deliberate handling. Fixed ports are easy to document, but they collide as soon as a second developer, a worktree, or an agent starts another copy of the stack on the same machine. Read the API port from an environment variable that has a default. Then derive the frontend's API base URL from that same variable and pass it to the frontend dev server, so the two values cannot drift apart. When several stacks run in parallel, give each one its own port offset or range. If you let the OS pick free ports instead, write the chosen port somewhere the frontend can read it, because a randomly assigned port the frontend cannot discover is no improvement over a collision.
// scripts/wait-for-api.mjs
// Run before the frontend dev server in the same command, e.g.:
// API_PORT=3401 VITE_API_BASE_URL= sh -c 'node scripts/wait-for-api.mjs && vite'
// Use one API_PORT per stack, such as 3401, 3402, and so on, so parallel copies don't collide.
import net from 'node:net';
// Use the same variable and default as the API, so both sides agree on the port.
const host = process.env.API_HOST ?? '127.0.0.1';
const port = Number(process.env.API_PORT ?? 3333);
const timeoutMs = Number(process.env.API_WAIT_TIMEOUT_MS ?? 30000);
const intervalMs = 250;
const deadline = Date.now() + timeoutMs;
// A successful TCP connect means something is listening.
// It does not mean the app is healthy. Poll a health endpoint if you need that.
function tryConnect() {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port });
socket.once('connect', () => { socket.destroy(); resolve(true); });
socket.once('error', () => { socket.destroy(); resolve(false); });
});
}
while (!(await tryConnect())) {
if (Date.now() > deadline) {
// Fail loudly instead of letting the frontend start against a dead port.
console.error(`API not listening on ${host}:${port} after ${timeoutMs}ms`);
process.exit(1);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
console.log(`API is listening on ${host}:${port}; starting frontend`);
A small Node wait-for-port step reads the same env-driven API_PORT as the backend and blocks the frontend dev server until the API port accepts connections.
Running several apps, caching rules, and process shutdown
Running several apps at once
nx run-many -t serve -p web admin starts both frontends and their continuous dependencies in a single pipeline. When web and admin both depend on the same API's serve target, you want one API process, not two processes competing for one port. Nx's task graph should deduplicate a shared task, but test that in your own workspace instead of assuming it. Run the command and look at the API's log prefix in the output. Then check how many processes are listening on the API port. If you see a second API process or an EADDRINUSE error, a target name or configuration likely differs between the two dependency declarations, and Nx is treating them as separate tasks.
Keep serve targets out of the cache
Set cache: false on every serve target. A dev server never produces a finished output, so there is nothing valid to replay. A cache hit would mean Nx reports success without starting a server. Keep continuous targets separate from cacheable build and test targets, and never make build depend on serve. Otherwise nx affected -t build test in CI starts servers that never exit, and the job hangs until it times out.
Shutdown and orphaned processes
Pressing Ctrl+C sends SIGINT to the foreground process group, and Nx then tries to stop its child tasks. Some children do not stop cleanly:
- Wrapper scripts that spawn grandchildren without forwarding the signal leave those grandchildren running.
- Watchers that detach from the process group keep running after the parent exits.
- A process running as PID 1 inside a container can ignore SIGTERM unless the image or
docker runprovides an init process.
To find leftovers, use these commands:
lsof -iTCP:3000 -sTCP:LISTENorss -ltnpshows which process holds a port.ps -ef --forestshows each process with its parent.docker pslists containers that are still running.
Also check debugger ports such as 9229. An orphaned --inspect process holding that port will stop the next debug session from attaching.
#!/usr/bin/env bash
# Find processes still holding dev ports after Ctrl+C on an Nx serve run.
# Placeholder ports: API on 3000, web on 4200, admin on 4300, Node debugger on 9229.
PORTS="3000 4200 4300 9229"
for port in $PORTS; do
echo "== port $port =="
# Which local process is listening? -t prints PIDs only.
pids=$(lsof -t -iTCP:"$port" -sTCP:LISTEN 2>/dev/null)
for pid in $pids; do
# Show the parent and process group. If PPID is 1, the original parent
# exited without stopping this child, so it is an orphan.
ps -o pid,ppid,pgid,etime,args -p "$pid"
done
# A port published by a container appears under docker-proxy or not at all
# in lsof, so ask Docker directly.
docker ps --filter "publish=$port" --format '{{.ID}} {{.Names}} {{.Ports}}' 2>/dev/null
done
# Watchers that detached from the process group may not hold a port.
# Search by command line instead.
pgrep -af 'nx|webpack|vite|nodemon|tsc --watch'
# Cleanup, after checking the output above:
# kill -TERM <pid> # ask the process to exit first
# kill -KILL <pid> # only if it ignores SIGTERM
# docker stop <container> # sends SIGTERM, then SIGKILL after a timeout
# Linux alternative to lsof: ss -ltnp 'sport = :3000'
A shell script that uses lsof, ps, pgrep, and docker ps to find orphaned dev servers, debuggers, watchers, and containers still holding ports after an interrupted Nx serve run, and lists the commands for stopping them safely.
When to avoid continuous tasks and what to use instead
Continuous tasks are convenient, but that convenience has costs worth weighing.
Implicit boots. A frontend developer who only touches CSS still pays for the API and its dependencies to start. That adds CPU and memory load and lengthens time-to-first-render on every nx serve.
Interleaved logs. When three processes write to one terminal, a stack trace from the API can end up between webpack rebuild messages. Nx prefixes output by project, but reading it is still slower than reading a dedicated pane.
Broad targetDefaults. A default that makes every serve depend on api:serve couples projects that have no real relationship. It also adds edges to the task graph that make nx graph harder to read.
CI hangs. An end-to-end job that starts continuous targets without readiness checks or a hard timeout can race against an API that is still starting, or block until the runner kills the job.
Alternatives
| Approach | Fits better when |
|---|---|
| Separate terminals | Developers want isolated logs and control over restarts |
| Generic process runner | The workspace mixes Nx and non-Nx processes, or you want named, colored panes without graph coupling |
| Docker Compose | You need stateful infrastructure such as databases or queues with persistent volumes |
| Shared or remote API | Most frontend work doesn't need local backend changes |
A common split is Compose for infrastructure and Nx for application code. This works, but it adds another layer to debug: a failing request could come from Nx task state, container networking, or both.
Impact on development cost and engineering time
The main saving is fewer manual steps. Without continuous tasks, a developer opens several terminals, starts the API, waits, then starts the web app, and must know that order in the first place. With the topology declared in the workspace, nx serve web encodes that knowledge. New team members inherit it from the project configuration instead of from a wiki page or a colleague.
Illustrative estimate (assumed numbers, not a benchmark)
Weekly minutes saved = developers × restarts per day × minutes saved per restart × working days
Assume 10 developers, 4 full restarts per day, 2 minutes saved per restart, and 5 days. That gives 10 × 4 × 2 × 5 = 400 minutes, or about 6.7 hours per week. Replace these inputs with your own team's numbers. If your restarts are rare or already scripted, the result may be close to zero.
The costs are also real:
- Machine resources. Every serve now starts the API and anything it depends on. On laptops with limited memory, that can slow the editor and the type checker.
- Debugging. When a readiness check passes too early or a child process survives shutdown, the symptom is a port conflict or a stale server. These problems take longer to trace than a crash in a single process.
Keep opt-out paths so the full graph is not mandatory:
- Frontend-only target. Add a serve target that has no dependency on the API, for engineers who only work on the UI.
- Remote API configuration. Add a configuration that points the app at a shared or staging API through environment variables, so no local backend process starts at all.
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.