Skip to content
Go back

Why Docker Builds Are Slow: Layer Cache Explained

By SumGuy 5 min read
Why Docker Builds Are Slow: Layer Cache Explained

You change one line of code, rebuild your Docker image, and it takes 5 minutes. The same image that built in 30 seconds last week. You’re staring at the build output wondering why RUN npm install is re-running when nothing in package.json changed.

Welcome to Docker layer caching. It’s powerful, it’s fast, and it’s easy to break.

How Layer Caching Works

Docker builds images in layers. Each instruction (FROM, RUN, COPY, etc.) creates a new layer. Docker caches each layer. When you rebuild, Docker checks if the layer’s inputs changed. If not, it reuses the cached layer instead of re-executing.

Here’s the key: Docker determines if a layer changed by comparing the instruction and the files it operates on.

Example:

Dockerfile
FROM node:18-slim
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
CMD ["npm", "start"]

The second time you build:

  1. FROM node:18-slim — cached (same base image)
  2. WORKDIR /app — cached (just a metadata change)
  3. COPY package.json .cache hit if package.json hasn’t changed
  4. RUN npm installcache hit if the previous layer was a cache hit
  5. COPY . .cache miss if any file in the directory changed
  6. RUN npm run buildre-runs because the previous layer missed
  7. CMD ["npm", "start"] — cached (metadata only)

This is efficient. You changed one .js file, so only the build step re-runs. npm install doesn’t re-run. Good.

The Common Cache-Killer: COPY Too Early

Most developers do this (and destroy their cache):

Bad Dockerfile
FROM node:18-slim
WORKDIR /app
COPY . . # ← Changes every time you edit a file
RUN npm install
RUN npm run build
CMD ["npm", "start"]

Now, when you edit any file and rebuild:

  1. COPY . .cache miss (contents changed)
  2. RUN npm installre-runs from scratch (takes 60+ seconds)
  3. RUN npm run build — re-runs
  4. Everything downstream is slow

You’re rebuilding node_modules even though package.json hasn’t changed. This kills your iteration speed during development.

The Fix: Layer Stability Order

Arrange your Dockerfile so stable, slow things come first:

Good Dockerfile
FROM node:18-slim
WORKDIR /app
# Stable: package.json rarely changes
COPY package.json package-lock.json ./
RUN npm install # Cached unless package.json changes
# Variable: source code changes frequently
COPY . .
RUN npm run build # Re-runs when you edit source
CMD ["npm", "start"]

Now, when you edit a .js file and rebuild:

  1. COPY package*.json . — cache hit
  2. RUN npm installuses cache (2ms, not 60s)
  3. COPY . . — cache miss (expected)
  4. RUN npm run build — re-runs (just the build, not npm install)

Total rebuild time: 10 seconds instead of 70. That’s the difference between a snappy feedback loop and a coffee break.

Multi-Stage Example

Here’s a production-grade Dockerfile for a Node app with good cache behavior:

Dockerfile
# Stage 1: Dependencies (slow, stable)
FROM node:18-slim AS base
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
# Stage 2: Builder (moderate, semi-stable)
FROM node:18-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 3: Runtime (fast, minimal)
FROM node:18-slim
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

Each stage gets its own cache. The runtime stage is tiny—just dependencies and built output. If you change docs or tests, only the builder stage re-runs.

Pro Tips for Cache Optimization

1. Wildcard COPY conservatively:

# Bad: Invalidates on any file change
COPY . .
# Good: Explicit about what you need
COPY package.json package-lock.json ./
COPY src ./src
COPY tsconfig.json ./

2. Combine RUN commands to reduce layers (when it makes sense):

# More layers, but easier to cache
RUN apt-get update
RUN apt-get install -y curl
# Fewer layers, less granular caching
RUN apt-get update && apt-get install -y curl

Combine only when you need to (e.g., dependencies that must be installed together).

3. Use .dockerignore to avoid spurious invalidations:

If your .dockerignore is missing, every file in your repo affects layer caching, even if you don’t copy it.

4. Check cache hits in the build output:

Terminal window
$ docker build -t myapp:v1 .
Step 3/7 : COPY package*.json ./
---> Using cache # ← Cache hit!
Step 4/7 : RUN npm install
---> Using cache # ← Cache hit!
Step 5/7 : COPY . .
---> 7a8f3c2d1e0b # ← Cache miss

The Benchmark

Same Node.js app, two approaches:

Bad (COPY . . early):

Good (package.json first):

The good approach is 5x faster for the most common case: iterating on code.

Checklist

Layer caching is free optimization. Use it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
Using the Clipboard from the Linux Terminal
Next Post
lsof: The Tool That Shows You Everything

Related Posts