Why ordinary task dependencies fail for dev servers

Nx treats dependsOn as a promise about completion. When web:serve declares that it depends on api:serve, Nx schedules api:serve first and starts web:serve only after the dependency exits successfully. That rule suits builds, since web:build should wait for lib:build to finish writing its output. A dev server breaks the rule because it is designed never to exit.

Here is what happens if you add api:serve to web:serve's dependsOn without marking it continuous. Run nx serve web and Nx starts the API. The API binds its port, logs that it is listening, and keeps running. Nx is still waiting for it to finish, so the web dev server never starts. If you press Ctrl+C, the API is interrupted instead of completing. Nx records that as a failure, and the dependent task still doesn't run. Nothing reports an error, so the command looks like it is just being slow.

Teams usually work around this in one of three ways:

  • Hand-maintained scripts that background the API, poll a port, and then start the frontend. These scripts duplicate dependency information that already exists in the project graph, and they often leave orphaned processes behind.
  • nx run-many across both projects, which runs the tasks side by side. It gives no guarantee about ordering, and every developer has to remember the right project list.
  • Extra terminal tabs, one per service, started by hand in the right order.

Each workaround moves knowledge out of the graph and into habit, and continuous tasks are designed to fix that.

What a continuous task is and how Nx schedules it

A continuous task is a target with "continuous": true in its configuration. The flag tells Nx that the process is not expected to exit. A dev server, a file watcher, or a local API that listens on a port all fit this description. The flag is about scheduling, not about what the command does. Nx still runs the same executor or shell command. It only changes how that target relates to the tasks that depend on it.

For an ordinary dependency, Nx waits for the task to exit successfully before starting anything downstream. That is correct for a build, but it deadlocks on a server that never exits. For a continuous dependency, Nx starts the task, does not wait for it to finish, and keeps it running alongside its dependents for as long as they run.

Consider a graph where web:serve declares three dependencies:

  • shared-types:codegen is finite. Nx runs it to completion first.
  • shared-ui:build is finite. Nx runs it to completion first.
  • api:serve is continuous. Nx starts it and lets it keep running.

web:serve starts only after both finite tasks have exited successfully. It does not wait for api:serve to exit, because that will never happen. You end up with both servers running side by side from one command. The dependency between them lives in the project graph, where every engineer and every CI job reads it the same way.

Support for this flag was added in a relatively recent Nx release. Check the documentation for the Nx version installed in your workspace to confirm it is available before you rely on it.

  TASK GRAPH: dependencies declared by web:serve

  +----------------------+  +-----------------+  +------------------------+
  | shared-types:codegen |  | shared-ui:build |  | api:serve              |
  | finite               |  | finite          |  | "continuous": true     |
  +----------+-----------+  +--------+--------+  +-----------+------------+
             |                       |                       |
             | exits 0               | exits 0               | started, not
             |                       |                       | awaited
             +-----------+-----------+                       |
                         |                                   |
                         v                                   |
             +-----------------------+                       |
             | web:serve             |<----------------------+
             | starts after finite   |
             | deps succeed          |
             +-----------------------+

  SCHEDULE OVER TIME  ------------------------------------------------>

  shared-types:codegen  [=====]
  shared-ui:build       [====]
  api:serve             [=============================================>
  web:serve                    [======================================>

Nx runs the finite codegen and build dependencies to completion before starting web:serve, while the continuous api:serve is started without being awaited and keeps running alongside it.

Setting it up: from project config to nx serve web

Start in the API project. On its serve target, set "continuous": true. This tells Nx the process is not expected to exit, so it should not wait for one before starting the tasks that depend on it. Do the same for web:serve, even if nothing depends on it today. Then, in the web project's serve target, add a dependsOn entry that points at the serve target of the api project. If every frontend in the workspace follows the same pattern, put the continuous flag and the dependency under targetDefaults in nx.json instead. That keeps the rule in one place, and individual projects can still override it.

One-shot and continuous dependencies can be mixed. If web:serve also depends on a generate-client target that produces a typed API client, Nx runs the generator to completion first, because it is a normal task. The continuous backend is started and left running, without waiting for it to exit.

Run nx serve web. Both processes should stream their output, and stopping the command should stop both. Add --graph to the same command to see the task graph Nx would execute. Confirm the edge from web:serve to api:serve, and check whether your Nx version marks continuous tasks in that view.

When you pass --configuration=e2e, Nx forwards that name to dependencies that define a configuration with the same name. Keep the names aligned (development, e2e) across both projects, or the API will silently fall back to its default.

Set cache: false on serve targets and leave out outputs. A server produces no artifact to restore, and replaying a cached log is not the same as starting the process.

// apps/web/project.json (Nx accepts comments in project.json)
{
  "name": "web",
  "targets": {
    "generate-client": {
      // One-shot task: runs to completion before serve starts
      "command": "node tools/generate-client.mjs",
      "outputs": ["{projectRoot}/src/generated"],
      "cache": true
    },
    "serve": {
      // Long-running dev server: never cache it, no outputs to restore
      "continuous": true,
      "cache": false,
      "executor": "nx:run-commands",
      "options": { "command": "vite --port 4200", "cwd": "apps/web" },
      "configurations": {
        "development": { "command": "vite --mode development --port 4200" },
        "e2e": { "command": "vite --mode e2e --port 4200" }
      },
      "defaultConfiguration": "development",
      "dependsOn": [
        // Finishes first, because it is a normal task
        "generate-client",
        // Started and left running; api must define 'serve' as continuous
        // and use the same configuration names (development, e2e)
        { "projects": ["api"], "target": "serve" }
      ]
    }
  }
}

A web project.json where serve is continuous and uncached, runs generate-client to completion, and starts the continuous api:serve alongside it, with configuration names that match the API project's.

Readiness, ports, environment and shutdown: what you still own

Nx treats a continuous dependency as satisfied the moment its process launches. It does not check whether the API is accepting connections. Your frontend can start while the backend is still compiling or running migrations, and the first requests fail.

Most setups can tolerate this window instead of eliminating it. A dev-server proxy that forwards /api to the backend will return errors until the backend is up and then start working, with no restart needed. Clients that retry idempotent requests with backoff handle the same gap at the application layer. When a command must not run until the port is open, such as a seed script or an e2e runner, add a small wait-for-port step to that project's target. Keep it in the project rather than in shared tooling, so the readiness rule stays with the service it describes.

Define each port in its own project's configuration and pass it explicitly to anything that consumes it. If two projects read the same env file, renaming a variable in one breaks the other without any warning. Logs from parallel tasks are interleaved, so add a per-service prefix to your log lines.

Ctrl-C does not behave the same everywhere. Terminal emulators, shells and operating systems differ in how they deliver signals to child processes, and some servers survive it. When a port is unexpectedly busy:

  • Run lsof -i :PORT or ss -ltnp to find the PID holding the port.
  • Check the command line to confirm it is a stale dev server.
  • Send kill PID first and use kill -9 only if the process ignores it.
  • Rerun ss -ltnp to confirm the port is free.
#!/usr/bin/env bash
# apps/api/scripts/wait-for-port.sh
# Blocks until HOST:PORT accepts TCP connections, or fails after a timeout.
# Keep this in the project that owns the service so the readiness rule
# stays next to the code it describes.
#
# Example use in a target that must not start early (seed script, e2e runner):
#   ./scripts/wait-for-port.sh 127.0.0.1 3333 60 && node scripts/seed.js
set -euo pipefail

host="${1:?usage: wait-for-port.sh HOST PORT [TIMEOUT_SECONDS]}"
port="${2:?usage: wait-for-port.sh HOST PORT [TIMEOUT_SECONDS]}"
timeout_s="${3:-60}"

# SECONDS is a bash builtin counter of seconds since the shell started.
deadline=$(( SECONDS + timeout_s ))

# /dev/tcp is a bash feature, not a real file. Opening it attempts a TCP connect.
# The subshell closes the descriptor when it exits, so no connection stays open.
until (exec 3<>"/dev/tcp/${host}/${port}") 2>/dev/null; do
  if (( SECONDS >= deadline )); then
    # Prefix log lines so they are easy to find in interleaved Nx output.
    echo "[wait-for-port] ${host}:${port} not accepting connections after ${timeout_s}s" >&2
    exit 1
  fi
  sleep 1
done

echo "[wait-for-port] ${host}:${port} is accepting connections"

A minimal, project-local bash script that waits until a port accepts TCP connections before a dependent command such as a seed script or e2e runner starts, and fails with a clear error after a timeout.

Using the same graph for e2e locally and in CI

Once stale processes are out of the way, the bigger payoff comes from attaching servers to the test target itself. Give the e2e project a target, typically e2e, whose dependsOn lists the serve targets of both web and api. Because those targets are marked continuous, Nx starts them and then launches the test runner without waiting for them to exit. When the tests finish, Nx tears the servers down. No one has to open three terminals, and no README has to explain the startup order.

This matters more in CI than on a laptop. Many pipelines carry a hand-written block that backgrounds the API, backgrounds the frontend, sleeps, and then runs tests. That block drifts away from how developers actually work. With the dependencies declared in the graph, the pipeline step and the local command are identical: nx e2e web-e2e. If someone adds a new service that the frontend needs, they add it to dependsOn once, and both environments pick it up.

The graph only guarantees that a process has started. It does not guarantee that the process is accepting requests. That gap is the usual cause of e2e runs that pass locally but fail intermittently in CI, where cold caches and shared runners make startup slower. If the first test fails with connection refused or a 502 and a rerun passes, suspect readiness before you suspect the test. Apply the mitigations from the previous section:

  • Add an explicit wait on the health endpoint.
  • Use the test runner's built-in server-wait option.
  • Pin ports so the tests and servers agree on where to connect.
// apps/web-e2e/project.json
// Assumes the `serve` targets in apps/web and apps/api
// both set "continuous": true in their own project.json.
{
  "name": "web-e2e",
  "projectType": "application",
  "implicitDependencies": ["web", "api"],
  "targets": {
    "e2e": {
      "executor": "@nx/playwright:playwright",
      "options": {
        "config": "apps/web-e2e/playwright.config.ts"
      },
      // Start both servers before the tests. Because they are
      // continuous, Nx does not wait for them to exit, and it
      // stops them when the test run finishes.
      "dependsOn": [
        { "projects": ["web", "api"], "target": "serve" }
      ]
    }
  }
}

// Same command on a laptop and in the CI step:
//   npx nx e2e web-e2e
// Started is not the same as ready: keep a health-endpoint
// wait or the runner's server-wait option, and pin ports.

An e2e target whose dependsOn lists the continuous serve targets of web and api, so a single nx e2e web-e2e starts both servers, runs the tests and stops the servers, both locally and in CI.

Who should use it, when to avoid it, and the alternatives

Continuous tasks fit best in a monorepo where a frontend genuinely cannot do useful work without a local API, and where backend and platform engineers already maintain a fragile "start everything" script. Moving that knowledge into dependsOn puts it in the project graph. Nx can then reason about it, cache the non-continuous prerequisites, and keep it in sync as projects change. A shell script cannot do any of that.

Limit or avoid continuous tasks in these cases:

  • Deep fan-out. If web depends on a gateway that depends on four services, every nx serve web starts all six. Developers working on a button pay the startup cost of the whole platform.
  • Implicit database-backed APIs. If an API runs migrations or seeds data on boot, starting it as a side effect of serving a frontend will surprise people. Make that dependency explicit and opt-in.
  • Heavy readiness requirements. Continuous tasks start processes. They don't verify that the processes are healthy. If correct startup needs ordered health checks, retries or warm caches, a dedicated tool handles that better.
Approach Strength Weakness
Continuous tasks Graph-aware, reused by e2e No real readiness model
Wrapper shell scripts Full control Drifts from project config
run-many with parallelism Simple, no config changes No dependency ordering
Process managers Restarts, health checks Separate config to maintain
Container-based stacks Production-like, isolated Slower feedback loop

A practical compromise is to define two targets. A full-stack serve target declares the continuous API dependencies. A frontend-only serve target points at a mocked or shared backend. Developers then choose how much of the system they start.

Migration path and impact on cost and engineering time

Migrate one edge at a time rather than rewriting every target at once. Pick the frontend-to-API dependency that people start manually most often and declare it as continuous. Leave nx serve api unchanged so backend engineers can still run the API alone without pulling in the frontend. Keep any existing wrapper scripts, such as shell scripts or concurrently-style launchers, until the graph covers everything they start. Then delete them, because two ways to boot the stack will drift apart. Add a short note in the repo that explains which targets fan out, which ports they bind, and which environment variables they expect. Without it, the graph is only discoverable by reading project config.

Illustrative estimate (assumed numbers, not a benchmark). The formula is: monthly hours saved = engineers × minutes saved per day × working days ÷ 60.

Assume the following inputs:

  • 8 engineers
  • 10 minutes saved per day on opening terminals, starting services in order, and running manual e2e setup
  • 20 working days per month

That gives 8 × 10 × 20 ÷ 60 ≈ 27 hours per month. Onboarding adds a one-off saving per hire if "run the stack" becomes a single command instead of a wiki page.

Subtract the costs before trusting that number:

  • Memory and startup time. Every fan-out keeps extra processes resident on laptops, and cold starts get slower as more services boot.
  • Maintenance. Readiness workarounds and port configuration need ongoing upkeep, and each new service adds to it.

If the team spends more than a few hours a month fixing flaky readiness checks or port collisions, the net saving shrinks quickly.

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.