Why plain dependsOn hangs local dev, and what a continuous task changes

In Nx, dependsOn means "finish this first." For build steps, that works: Nx runs api:build, waits for it to exit, then runs web:build. Apply the same rule to dev servers and it breaks. If web:serve declares a dependency on api:serve, Nx starts the API and then waits for it to exit. A dev server runs until you stop it, so the frontend never starts and the terminal looks hung.

Most teams work around this in one of two ways. Some open a terminal per service. Others write a root script that uses a concurrency tool to start every server in parallel. Both approaches work, but both move the dependency graph out of Nx. Nx can no longer tell that the frontend needs the API, so nx serve web on its own doesn't bring up the stack. The scripts also drift as services are added or renamed.

A continuous task fixes the waiting rule. You add continuous: true to the API's serve target. Nx then starts that target and treats it as running indefinitely. A dependent task starts once the continuous task has launched, without waiting for it to exit. In the "before" config, api:serve is an ordinary target. In the "after" config, the only change is that flag, and web:serve keeps its existing dependsOn entry.

This matters most to backend and platform engineers who maintain local stacks with several services. Continuous tasks are a fairly recent feature, so check the Nx documentation for your workspace's version before you copy any configuration.

// BEFORE: apps/api/project.json
// Nx waits for api:serve to exit, which a server never does.
{
  "name": "api",
  "targets": {
    "serve": {
      "executor": "nx:run-commands",
      "options": { "command": "node apps/api/src/main.js" }
    }
  }
}

// AFTER: apps/api/project.json
// The only change is the flag. Nx starts the server, treats it as
// long-running, and lets dependents start once it has launched.
{
  "name": "api",
  "targets": {
    "serve": {
      "executor": "nx:run-commands",
      "continuous": true,
      "options": { "command": "node apps/api/src/main.js" }
    }
  }
}

// UNCHANGED: apps/web/project.json
// web:serve still declares that it needs api:serve.
{
  "name": "web",
  "targets": {
    "serve": {
      "continuous": true,
      "dependsOn": [{ "projects": ["api"], "target": "serve" }]
    }
  }
}

// Usage: one command starts the API, then the frontend.
// $ npx nx serve web

Adding continuous: true to the API serve target lets nx serve web start the API and then the frontend, without waiting for the API to exit.

Wiring a full local stack behind one nx serve

Mark every long-running target with "continuous": true in its target configuration. That flag tells Nx the task is not expected to exit, so dependents can start once it is running instead of waiting for it to finish.

The frontend's serve target lists three dependencies in dependsOn:

  • The API's serve.
  • build:watch on the shared library.
  • A codegen watcher that regenerates typed clients from the API's OpenAPI or GraphQL schema.

To point at a target in another project, use the object form, which names the projects and the target, for example projects api and target serve.

The ^ prefix is for a different case. ^build:watch runs build:watch on every project the frontend depends on in the project graph. That covers the shared library, because the frontend imports it, and it keeps covering new libraries as you add imports. The API usually never appears in the frontend's import graph, so ^ would miss it. Reference the API explicitly.

Model the database or message broker as its own continuous target, for example infra:up, running docker compose up through nx:run-commands. Leave off -d. A detached container returns immediately, so Nx would treat it as finished and could not stop it when you exit. In the foreground, the compose process belongs to Nx, its logs go through Nx, and Ctrl+C stops it. Make api:serve depend on infra:up.

nx serve frontend
├── api:serve            (continuous)
│   └── infra:up         (continuous, docker compose up)
├── shared:build:watch   (continuous, via ^build:watch)
└── api-client:codegen   (continuous, schema watcher)
// apps/frontend/project.json (Nx reads project.json as JSONC, so comments are allowed)
{
  "name": "frontend",
  "targets": {
    "serve": {
      // Never exits; anything depending on frontend:serve starts once it is up
      "continuous": true,
      "executor": "nx:run-commands",
      "options": {
        "command": "vite",
        "cwd": "apps/frontend"
      },
      "dependsOn": [
        // Explicit project reference: the API is not in frontend's import graph,
        // so ^ would never reach it
        { "projects": ["api"], "target": "serve" },

        // ^ form: run build:watch on every library frontend imports
        // (covers shared today and any library added later)
        "^build:watch",

        // Schema watcher that regenerates the typed client from the API's spec
        { "projects": ["api-client"], "target": "codegen" }
      ]
    }
  }
}

The frontend's serve target is marked continuous and depends on the API's serve and the client codegen watcher by explicit project reference, and on its imported libraries' build:watch through the ^ form.

How startup, readiness, and shutdown actually work

When you run nx serve frontend, Nx resolves the graph, starts each continuous dependency, and then starts frontend:serve without waiting for those dependencies to exit, because they never will. They stay alive as long as the requesting task does. When you press Ctrl+C, Nx stops the top-level task and signals the continuous processes it launched, so the API, the watchers, and infra:up all wind down together. You do not need to hunt through three terminals to stop them.

Started does not mean ready. Nx knows that a process exists, not that it is accepting connections. frontend can boot before the API is listening, and it can import the generated client before codegen has written its first file. There are three ways to handle this:

  • Tolerate the gap in the client. Use dev-proxy retries or a reconnecting fetch wrapper so that early requests fail softly instead of crashing the app.
  • Wait explicitly. Have the dependent's start script block until a port or health endpoint responds before it launches the server.
  • Run one-shot, then continuous. Give shared:build:watch a dependsOn of build, which is a normal, non-continuous target. Nx runs the full build to completion before the watcher starts, so dist output exists before anything imports it. This removes the "module not found on first boot" race.

Shutdown is only as clean as your tools allow. Processes that daemonize, detach (docker compose up -d), or spawn grandchildren that ignore signals can outlive Nx. They leave orphaned workers or hold ports open. After you stop the command, check docker ps and the listening ports on your own machine to confirm that everything actually exited.

{
  "name": "shared",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "outputs": ["{workspaceRoot}/dist/libs/shared"],
      "options": {
        "command": "tsc -p libs/shared/tsconfig.lib.json"
      }
    },
    "build:watch": {
      "executor": "nx:run-commands",
      "continuous": true,
      "dependsOn": ["build"],
      "options": {
        "command": "tsc -p libs/shared/tsconfig.lib.json --watch --preserveWatchOutput"
      }
    }
  }
}

In libs/shared/project.json, the one-shot build target must finish before the continuous build:watch target starts, so dist output already exists when frontend:serve, which lists shared:build:watch in its own dependsOn, first imports the library.

Continuous tasks vs nx watch vs separate terminals

Continuous tasks are the right fit when the tool already has a watch or dev mode, such as a dev server, tsc --watch, or a bundler in watch mode. Nx starts the process once, keeps it alive, and leaves rebuilds to the tool, which tracks its own dependency graph more precisely than Nx can from file events.

nx watch covers the opposite case: a tool with no watch mode. For example, nx watch --projects=shared-schema -- npm run codegen re-runs code generation whenever a file in shared-schema changes. Nx supplies the file watching and the tool only has to run once per change. The main risk is writing generated output into a watched project. Each run changes files, those changes trigger another run, and you get a rebuild storm or an endless loop. Write outputs outside the watched projects, or make sure the generated paths are ignored, before you rely on it.

Separate terminals and ad-hoc concurrency scripts work without any setup, but they don't know about the project graph. Startup order depends on you, and orphaned processes are your problem when something crashes.

Approach Fit Restart behavior Log handling Cleanup
Continuous tasks Tools with a watch/dev mode Tool handles rebuilds; Nx keeps it running Grouped per task by Nx Nx signals the whole tree on exit
nx watch Tools without a watch mode Full re-run on each change Interleaved output from one command Stops with the watch process
Terminals / scripts Quick experiments Manual or script-defined One terminal per process, or a prefixed stream Manual

The rule: use a continuous task if the tool can watch itself. Use nx watch if it can't. Use separate terminals only for throwaway debugging.

# Tool WITHOUT a watch mode: let Nx do the file watching.
# Re-run codegen whenever a file in the shared-schema project changes.
nx watch --projects=shared-schema -- npm run codegen

# Watch several projects. Nx sets NX_PROJECT_NAME for each run.
# Escape the $ so the variable expands when Nx runs the command,
# not when your shell parses this line.
nx watch --projects=shared-schema,api-contracts -- echo \$NX_PROJECT_NAME changed

# Avoid feedback loops: codegen should write to a project that is NOT
# watched, for example libs/generated-client, never into libs/shared-schema.
# If generated files land inside a watched project, each run triggers
# the next one and you get a rebuild storm.

# Tool WITH a watch mode: don't wrap it in nx watch.
# Mark the target as continuous in project.json and run it once:
#   "serve": { "continuous": true, ... }
nx run web:serve

Shows nx watch re-running a codegen command for a tool with no watch mode, with outputs kept outside the watched project to prevent feedback loops, and a dev server left to a continuous task.

Reading logs and avoiding port collisions

When several continuous tasks share one terminal, their output interleaves. How Nx labels that output depends on your version and output style. In streamed output, each line is prefixed with the project name, so you can tell api from worker at a glance and pipe the stream through grep to follow one service. Recent Nx versions also offer an interactive terminal UI that gives each task its own pane, which you can select and scroll independently. Check nx run --help for the output-style options your installed version supports.

Even with prefixes, interleaved logs are harder to read. Stack traces from two processes can land in the same second, and a noisy bundler can push one useful error off screen. When you're debugging a single service, especially one attached to a debugger, it is reasonable to start its dependencies once and run that one service in its own terminal. Continuous tasks are for bringing the stack up. They don't require you to read it as one stream.

Port collisions are the other common problem. Many dev servers fall back to a default port, or pick the next free one when it's taken, and then nothing else in the stack knows where they went. Set an explicit port for each service in its target configuration, and point dependents at that value rather than at the tool's default. When two worktrees or two engineers share a machine, read the port from an environment variable with a fixed fallback. Each checkout then runs on its own ports, so one stack can't talk to the other's backend by accident.

When to avoid continuous tasks

Continuous tasks solve one problem well: keeping long-running processes alive next to each other. Several situations call for something else.

Anything that needs a reusable output

A continuous task never finishes, so Nx has no completed result to hash or store. It cannot be cached, and no other task can consume its output. If CI, a container image, or a downstream target needs compiled artifacts, keep a separate non-continuous build target and point those consumers at it. Treat the continuous target as a convenience for local dev only.

Dependency chains that reach CI

dependsOn is transitive. Suppose an e2e or integration target depends on serve, and serve depends on a continuous API server. Running that target in CI may start servers nobody needs, or the pipeline may never exit. Audit the full chain for every target CI invokes. If CI really needs a running server, give it an explicit target built for that purpose.

Tools that misbehave as children

Nx manages a continuous task as a foreground child process. Some tools daemonize, fork into the background, or ignore SIGINT and SIGTERM. When Nx shuts down, those tools leave orphaned processes holding ports. Wrap them in a script that stays in the foreground and forwards signals, or run them outside Nx.

Engineers who touch one service

A single serve that starts the whole stack costs startup time and memory for someone editing one handler. Offer narrower entry points, such as serve for the service alone and serve:full for the stack with all its dependencies.

Impact on cost and engineering time, plus a verification checklist

The trade-off is between shared, predictable setup and per-engineer resource cost. A single entry point means every laptop starts the same processes in the same order. Onboarding becomes one documented command, and nobody has to maintain a hand-written concurrency script that drifts from the project graph. The price is startup time and memory for engineers who only touch one service, which the narrower targets above are meant to absorb.

Concern Continuous tasks Custom scripts or separate terminals
Setup consistency Defined in project config, identical for everyone Varies per engineer
Maintenance Lives alongside the task graph Separate script to keep in sync
Resource use Whole dependency chain unless a narrower target exists Only what you start
Shutdown One Ctrl+C stops the tree Manual and easy to orphan

Illustrative estimate (assumed numbers, not a benchmark)

Use this formula:

net hours/month = (engineers × drift incidents per engineer × hours per incident) + (new hires × onboarding hours saved) − (engineers × full-stack starts per engineer × extra minutes per start ÷ 60)

Assume 10 engineers, 1 drift incident each at 1.5 h, 1 new hire saving 4 h, and 40 full-stack starts per engineer at 1 extra minute each. That gives 15 + 4 − 6.7 ≈ 12 hours per month. The last term is the most sensitive one, and it shrinks when people use narrower targets.

Verification checklist

  • Run nx reset, then run the top-level serve command from a fresh terminal.
  • Confirm each service logs its ready message and answers on its expected port.
  • Edit a shared library and confirm every dependent service picks up the change.
  • Stop everything with a single Ctrl+C.
  • Check for orphaned processes with ps aux | grep node.
  • Confirm no ports remain bound, using lsof -i :<port> on macOS or ss -ltnp on Linux.

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 .env by 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.