Docker is slowing local development: fix the startup tax

By Geekery Team · · 6 min read

Dockerized local dev environments are slowing your engineering velocity because they add an operating-system abstraction between a developer and the code they need to change. Containers are excellent for production parity and CI, but the local feedback loop matters more. If a developer waits 40 seconds for a database, 90 seconds for an API and another two minutes for the queue, every small change becomes a scheduling exercise rather than a focused coding session.

That is the problem I see in UK engineering teams, from BBC service squads to NHS Digital platform teams: Docker gets adopted as a local-development default because it works on a laptop, then quietly becomes the reason the laptop feels slow. The fix is not to ban Docker. It is to stop using the full production topology to develop one service.

The hidden cost is the feedback loop

Local development has a brutal metric: the time between changing code and knowing whether the change is correct. Every container image pull, port check, healthcheck and service restart lengthens that loop. The raw container startup number is only part of it. The real cost is the orchestration around startup: waiting for health checks, retrying migrations, resolving stale volumes and rebuilding an image after a dependency changed.

Consider a typical stack with PostgreSQL, Redis, RabbitMQ, an API and a worker. Docker Compose can bring that up reliably, but reliable is not the same as fast. Sequential health checks alone can add 30 seconds. A base image download on a cold cache can add another 30 to 60 seconds, depending on the registry, connection and disk. Multiply that across a ten-service branch and developers start batching unrelated changes just to avoid paying the startup cost repeatedly.

Docker also hides host resources. A developer can have four services running, two databases and an IDE indexer, with no obvious relationship between CPU pressure and the service they are debugging. On a UK business laptop constrained by corporate endpoint software and virtualisation, that becomes an even more reliable way to turn a two-minute test into a ten-minute reset.

Profile the slow path before replacing anything

Do not start with a sweeping rewrite. Measure the path your team actually uses. Record cold-start time, warm-start time, rebuild time and the time to run one meaningful test. Separate image pulls from container creation, and container creation from application readiness. Docker Desktop, Colima, Rancher Desktop and native Docker Engine also behave differently, so measure the platform your team has rather than quoting a benchmark from somebody else's Mac.

A runnable startup timing script

This Bash snippet uses Docker's own timestamps to show where startup time is going. Run it against the same project on a clean and a warm cache:

#!/usr/bin/env bash
set -euo pipefail

started_at=$(date +%s%3N)

docker compose up -d --wait
ready_at=$(date +%s%3N)

docker compose exec -T api sh -c 'for i in $(seq 1 100); do
  wget -qO- http://localhost:8080/health >/dev/null && exit 0
  sleep 0.1
done
exit 1'
healthy_at=$(date +%s%3N)

printf 'compose ready: %d ms\n' "$((ready_at - started_at))"
printf 'api healthy: %d ms\n' "$((healthy_at - ready_at))"

Run it with bash scripts/time-startup.sh. Keep the output in a team dashboard, but do not optimise a number without checking the developer experience. A stack that starts in 12 seconds and then takes three minutes to pass a test is still slow; a stack that starts in 30 seconds but gives instant feedback may be perfectly acceptable.

Run dependencies natively where the risk is low

The pragmatic move is to classify each dependency. PostgreSQL, Redis and RabbitMQ are often easier to install and run directly on the host, using Homebrew on macOS and the packages available through apt on Ubuntu. That removes image startup and Docker networking from the inner loop while keeping application code containerised. For teams that cannot install services globally, use lightweight local binaries or managed development instances rather than five production-shaped containers.

Do not sacrifice parity where it matters. If the service depends on a specific Postgres extension, an unusual filesystem, a message broker behaviour or a system library, keep the relevant container or use a dedicated integration environment. The point is to isolate the parts of the stack that are genuinely difficult to reproduce natively.

A useful split looks like this:

  • Run natively: application processes, language runtimes, format tools and test runners.
  • Run in containers: uncommon system dependencies, integration tests and occasional parity checks.
  • Run remotely: shared queues, secrets and expensive infrastructure that is not needed for every test.
  • Do not localise: dependencies whose versions or behaviour your team cannot reliably reproduce.

Then tune the filesystems that containerise the application. Bind-mounting source code is a major source of sluggishness on macOS and Windows because file events cross a virtualised boundary. On macOS, the current virtiofs implementation in Docker Desktop is usually better than the legacy osxfs driver. On Linux, confirm that the workspace is inside a supported filesystem and that the application is not recursively watching generated directories such as node_modules, .venv or target.

Rebuild less, cache more

The second half of the problem is rebuilding. A Dockerfile that copies the entire repository before installing dependencies defeats most layer caching. A small source change should not invalidate the dependency layer. Use lock files, deterministic package commands and separate dependency installation from source copying. With BuildKit, local cache export and persistent builders reduce repeated downloads.

# syntax=docker/dockerfile:1.7

FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:22-bookworm-slim AS dev
WORKDIR /app
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

Use a target-specific development image rather than the production image with an overridden command. Keep production images minimal and immutable, but do not make developers run them. A production Dockerfile should optimise deploy size, security and runtime behaviour; a local Dockerfile should optimise rebuild time and editability.

Also stop rebuilding when the application can reload. Vite, Nx, Turborepo and language-specific watch modes can invalidate only the affected graph. A full container rebuild for every hot save is wasted work. If the app uses a compiled runtime, configure source maps and incremental compilation before reaching for a faster virtual machine.

Make one-command startup the exception

Container orchestration still has a place in local development. It is useful for onboarding, CI parity, testing complete topologies and reproducing production incidents. The mistake is making that path the only path. Maintain two workflows: a fast path for routine feature work and a full-stack path for integration work.

Document the fast path clearly. A new UK-based developer should be able to install the runtime, clone the repository and run one command to start the service they own. The full Compose stack can remain one command away, not ten implicit commands spread across a wiki. Put health checks in compose.yaml, keep environment defaults in a checked-in development file, and remove credentials that do not belong in the repository.

Finally, measure outcomes rather than container count. Track median time to first useful request, time from code save to test result, and the number of environment resets per day. Dockerized local dev environments are slowing your engineering velocity when those numbers rise and the team blames itself for poor focus. Move the cheap dependencies out of containers, keep containers where they provide real value, and make the feedback loop boring again.

Frequently asked questions

Should developers use Docker or native services locally?

Use native services for fast, common dependencies such as Postgres or Redis when the team can standardise versions. Keep Docker for uncommon dependencies, integration tests and full-stack parity. Neither option wins universally; the correct choice is the one that keeps the feedback loop short and reproducible.

How can I speed up Docker Compose startup?

Use pre-pulled or locally cached images, avoid unnecessary health-check delays, parallelise independent services and make readiness checks reflect actual dependency readiness. Profile cold and warm starts separately. On macOS and Windows, also check bind-mount performance and the current Docker Desktop filesystem driver.

Is Docker slowing down local development?

Docker can slow local development when every dependency is containerised, images are rebuilt repeatedly, or virtualised file mounts delay code reloads. It also adds consistency, isolation and easy parity with CI. Measure time to first useful request and time to test result before deciding which parts of the stack need to change.