Skip to content
R.

How can I ensure Compose services wait for a backend to be ready without brittle sleep loops?

Use Compose healthchecks and `depends_on` conditions to wait for real backend readiness instead of guessing with sleep loops.

About 11 min readComments

Compose can launch services in a valid order and still leave a dependent application racing ahead of a backend that is not ready. That is why sleep loops feel tempting: they try to cover a contract Compose never promised.

The better approach is to let the dependency prove readiness with a healthcheck and then ask Compose to wait for `service_healthy`. This article uses the official Docker documentation and a deterministic local fixture to show why “running” is not the same as “ready,” and why the difference matters in real startup sequences.

A container can be running before the backend is usable

The problem in this article is not whether Compose can start containers in a sensible order. It can. The more important question is whether that order is strong enough to keep a dependent service from starting too early. Docker’s own documentation draws that line clearly: Compose starts containers in dependency order, but a dependency being started is not the same thing as being ready to accept real work.

That distinction is what turns a harmless startup sequence into a flaky deployment. A backend can be running while it is still loading data, finishing internal bootstrap tasks, replaying recovery, or waiting for a health endpoint to become truthful. If the app starts in that window, it may see connection failures, refused logins, or application-level errors even though the backend container already exists.

A fixed sleep only papers over that timing gap. It does not describe the real condition the app needs, and it can be too short on a slow boot or too long on a fast one. The better answer is to make readiness explicit so the orchestration layer waits on a measured signal instead of a guess.

main.mjs / javascript
export function evaluateStartability(services, targetServiceName) {
  if (!services || typeof services !== 'object' || Array.isArray(services)) {
    throw new TypeError('services must be an object')
  }

  const target = services[targetServiceName]
  if (!target) throw new Error(`service not found: ${targetServiceName}`)

  const deps = target.depends_on ?? {}
  const normalizedDeps = {}

  if (Array.isArray(deps)) {
    for (const name of deps) normalizedDeps[name] = { condition: 'service_started' }
  } else {
    for (const [name, val] of Object.entries(deps)) {
      if (val == null || val === 'short') {
        normalizedDeps[name] = { condition: 'service_started' }
      } else if (typeof val === 'string') {
        normalizedDeps[name] = { condition: val }
      } else {
        normalizedDeps[name] = { condition: val.condition ?? 'service_started' }
      }
    }
  }

  const reasons = []
  for (const [depName, opts] of Object.entries(normalizedDeps)) {
    const dep = services[depName]
    if (!dep) {
      reasons.push({ dep: depName, ok: false, why: 'missing' })
      continue
    }

    if (dep.state !== 'running') {
      reasons.push({ dep: depName, ok: false, why: `not running (${dep.state})` })
      continue
    }

    if (opts.condition === 'service_healthy') {
      if (!Object.prototype.hasOwnProperty.call(dep, 'health') || dep.health == null) {
        reasons.push({ dep: depName, ok: false, why: 'no healthcheck defined' })
        continue
      }
      if (dep.health !== 'healthy') {
        reasons.push({ dep: depName, ok: false, why: `health=${String(dep.health)}` })
        continue
      }
    }

    reasons.push({ dep: depName, ok: true, why: 'satisfied' })
  }

  return { ok: reasons.every((r) => r.ok), reasons }
}

export function makeService(state = 'running', health = null, depends_on = undefined) {
  return { state, health, depends_on }
}

SourcesDocker Compose startup order (opens a new tab)Docker Compose services reference (opens a new tab)

What short `depends_on` actually guarantees

The short syntax of `depends_on` has a real job, but it is a narrow one. Docker’s services reference says Compose creates services in dependency order and guarantees dependency services have been started before starting the dependent service. That is useful for basic sequencing and shutdown order. It is not a promise that the backend can answer requests, finish migrations, or satisfy a login check.

This is why the article’s fixture treats short syntax and readiness as different states. If the dependency is merely running, a short `depends_on` relationship is satisfied. If the application really needs a backend that is healthy, then running alone is not enough. Those two cases look similar in a container list, but they are not interchangeable in a real startup sequence.

The practical consequence is important: using `depends_on` without a readiness condition can make a deployment appear stable while still leaving a race window open. The article’s point is not that short syntax is wrong. It is that it solves ordering, not readiness, and the difference matters any time the backend has a boot phase after process start.

  • Short `depends_on` gives you startup order, not readiness.
  • `service_started` is useful when order is enough and timing does not matter.
  • The bug appears when the dependent assumes the backend is already safe to use.

SourcesDocker Compose startup order (opens a new tab)Docker Compose services reference (opens a new tab)

Use a healthcheck on the dependency and wait for `service_healthy`

The repair is to move readiness out of timing guesses and into the dependency itself. Docker documents `condition: service_healthy` as the way to ask Compose to wait for a healthy dependency before starting a dependent service. That condition is only meaningful when the dependency also defines a `healthcheck`, because the health status comes from that check rather than from container startup alone.

This is the part that makes the pattern robust. A database can report healthy only when it can answer the query the app actually needs. A cache can report healthy only when it is listening and responsive. An auth service can report healthy only when its own bootstrap work is complete. The healthcheck is not generic decoration; it is the service’s statement of readiness.

The useful mental model is simple: startup order tells Compose which service to launch first, while health-based gating tells Compose whether the dependent may proceed. Once those are separated, the orchestration rule becomes honest. The backend has to prove it is ready, and only then does the dependent begin.

  • `condition: service_healthy` is the readiness gate, not a delay timer.
  • The healthcheck belongs on the service that knows how to prove it is ready.
  • The check must reflect the actual work the dependent needs, not just an open port.

SourcesDocker Compose startup order (opens a new tab)Docker Compose services reference (opens a new tab)

The supplied fixture proves the contract, not a magic delay

The supplied implementation is intentionally small. It does not simulate Docker, networking, container logs, or the full Compose engine. What it does is evaluate whether a target service may begin based on the state of its dependencies and the requested dependency condition. That is enough to show the contract the article is about: running is different from healthy, and health-based gating changes the answer.

The important positive case is the one readers want to trust before they ship: when the backend is running and healthy, a dependent service with `condition: service_healthy` is allowed to start. The important negative case is equally valuable: if the backend is running but has no healthcheck, the dependent stays blocked because Compose has no readiness signal to honor. A third case matters too: a backend that is still in `starting` state is not ready even if it already exists as a container.

That is why the fixture is useful even though it is not Docker itself. It lets the article make a narrow claim with a deterministic result. The test proves the decision boundary in the orchestration rule, while the official documentation supplies the actual Compose semantics behind that rule.

  • The example distinguishes three states: running, healthy, and blocked.
  • A missing healthcheck is a real failure case, not a minor detail.
  • The fixture demonstrates the contract without pretending to be the Docker engine.

SourcesDocker Compose startup order (opens a new tab)Docker Compose services reference (opens a new tab)

Why sleep loops and shallow probes are still incomplete fixes

The article keeps sleep loops in the category of incomplete fixes because they guess at time instead of observing state. A sleep can appear to work in a local demo while still failing under slower cold starts, image changes, or heavier load. It reduces the chance of the race without removing the race itself.

A healthcheck is stronger, but it still has to be honest. A probe that only checks that a process is listening can report success before the service can actually do the work the dependent requires. That is why the healthcheck should match the real dependency boundary, not just the easiest thing to measure. If the app needs a database that accepts queries, the check should verify that. If it needs a service that has completed bootstrap, the check should verify that too.

This is where the article’s applicability limit matters. `service_healthy` is not magic if the healthcheck is superficial, and it does not replace application-level retries for transient failures after startup. What it does is move the startup contract into a form Compose can observe and honor. That is much better than burying the contract inside a delay that nobody can inspect.

  • A fixed sleep can hide the race on one machine and fail on another.
  • A shallow probe can still be wrong if it does not test the real readiness condition.
  • A retry loop is still useful, but it should be a fallback, not the primary startup contract.

SourcesDocker Compose startup order (opens a new tab)Docker Compose services reference (opens a new tab)

What to carry into a real Compose file

The practical pattern is straightforward. Keep the backend’s healthcheck close to the backend definition, and make the dependent wait on `service_healthy` when it truly needs that backend to be ready. Use short `depends_on` only when startup order is sufficient and readiness is irrelevant. That keeps the Compose file honest about what each service actually needs before it starts.

The best final check is to ask what happens if the backend takes five or ten seconds longer than usual. If the answer is “the app may still start too early,” then the file still depends on timing guesswork instead of a readiness contract. A correct Compose configuration makes that question boring: the backend is either healthy or it is not, and the dependent service waits for the answer.

That is the real value of this pattern. It does not claim to solve every runtime failure. It gives startup a visible condition, prevents the worst kind of early launch race, and leaves the app free to handle later failures with its own retry logic if needed.

  • Use short `depends_on` when order is enough.
  • Use `service_healthy` when the dependent needs a real readiness signal.
  • Treat the healthcheck as part of the service contract, not as boilerplate.
main.test.mjs / javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { evaluateStartability, makeService } from './main.mjs'

test('allows a dependent service when the backend is running and healthy', () => {
  const services = {
    db: makeService('running', 'healthy'),
    app: makeService('created', null, { db: { condition: 'service_healthy' } })
  }

  const result = evaluateStartability(services, 'app')
  assert.equal(result.ok, true)
  assert.deepEqual(result.reasons, [{ dep: 'db', ok: true, why: 'satisfied' }])
})

test('blocks startup when service_healthy is requested but the dependency has no healthcheck', () => {
  const services = {
    db: makeService('running', null),
    app: makeService('created', null, { db: { condition: 'service_healthy' } })
  }

  const result = evaluateStartability(services, 'app')
  assert.equal(result.ok, false)
  assert.equal(result.reasons.length, 1)
  assert.deepEqual(result.reasons[0], {
    dep: 'db',
    ok: false,
    why: 'no healthcheck defined'
  })
})

test('short depends_on only requires running, not health', () => {
  const services = {
    db: makeService('running', null),
    app: makeService('created', null, ['db'])
  }

  const result = evaluateStartability(services, 'app')
  assert.equal(result.ok, true)
  assert.deepEqual(result.reasons, [{ dep: 'db', ok: true, why: 'satisfied' }])
})

test('rejects non-running and missing dependency cases', () => {
  const notRunning = {
    db: makeService('starting', 'starting'),
    app: makeService('created', null, { db: { condition: 'service_started' } })
  }
  const missing = {
    app: makeService('created', null, { db: { condition: 'service_started' } })
  }

  const r1 = evaluateStartability(notRunning, 'app')
  assert.equal(r1.ok, false)
  assert.equal(r1.reasons[0].why, 'not running (starting)')

  const r2 = evaluateStartability(missing, 'app')
  assert.equal(r2.ok, false)
  assert.deepEqual(r2.reasons, [{ dep: 'db', ok: false, why: 'missing' }])
})

SourcesDocker Compose startup order (opens a new tab)Docker Compose services reference (opens a new tab)

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed