Caching in CI/CD: The Easiest Win Most Teams Leave on the Table

by Arif Ikhsanudin, Backend Developer

Downloading the Same 400 MB on Every Run

Every time your CI pipeline runs, it downloads your dependencies. For a Spring Boot project with a moderate dependency tree, that's 150–400 MB of JAR files from Maven Central. For a Node.js project, it might be 300 MB in node_modules. For a Python service with NumPy, SciPy, and Pandas, it can be 500 MB or more.

This download happens on every run — every push, every PR, every retried build — because CI runners are ephemeral. They start clean, do the work, and terminate. The next run starts from scratch.

Without caching, a typical Java service wastes 2–4 minutes per pipeline run on dependency resolution. Across a team of 10 pushing 30 commits per day, that's 60–120 minutes of pure download time daily, contributing nothing to the quality of the build.

The Three Caching Strategies

Dependency caching is the most impactful and easiest to implement. Most CI platforms support it natively.

# GitHub Actions: Java/Gradle
- uses: actions/setup-java@v4
  with:
    java-version: '21'
    distribution: 'temurin'
    cache: 'gradle'

# GitHub Actions: Node.js
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'

# GitHub Actions: Python
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

These actions automatically handle cache key generation (based on the hash of your lockfile) and cache restoration. If the lockfile hasn't changed, dependencies are restored from cache. If it has, new dependencies are downloaded and the cache is updated.

Build output caching goes further — caching compiled classes, test results, and intermediate build artifacts so that unchanged modules don't need to be recompiled. Gradle's build cache does this particularly well:

// settings.gradle.kts
buildCache {
  local {
    directory = File(rootDir, "build-cache")
    removeUnusedEntriesAfterDays = 30
  }
  // For teams: use a remote build cache server
  remote<HttpBuildCache> {
    url = uri("https://build-cache.internal/cache/")
    credentials {
      username = System.getenv("GRADLE_CACHE_USER")
      password = System.getenv("GRADLE_CACHE_PASSWORD")
    }
    push = System.getenv("CI") == "true"  // Only push from CI, not local builds
  }
}

With a remote build cache, if engineer A already compiled module X today, engineer B's CI run can reuse that compiled output instead of recompiling. On large codebases, this eliminates most of the compile time.

Docker layer caching is the third tier. Docker builds are layer-based, and layers are only rebuilt when their inputs change. The key is structuring your Dockerfile to maximize cache reuse:

# Bad: every build is a full rebuild because the COPY invalidates everything
FROM eclipse-temurin:21-jre
COPY . .
RUN ./gradlew bootJar

# Good: dependencies layer is stable across most builds
FROM eclipse-temurin:21-jre AS deps
WORKDIR /app
COPY build.gradle settings.gradle gradle/ ./
RUN ./gradlew dependencies --no-daemon

FROM deps AS build
COPY src/ src/
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:21-jre
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

In GitHub Actions, use docker/build-push-action with cache-from and cache-to pointing to your container registry:

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: myrepo/myapp:latest
    cache-from: type=registry,ref=myrepo/myapp:cache
    cache-to: type=registry,ref=myrepo/myapp:cache,mode=max

Cache Key Design

A cache key has two requirements: it must be stable enough that cache hits are frequent, and it must change when the cached content is invalid.

For dependency caches, the lockfile hash is the right key — pom.xml, package-lock.json, requirements.txt, go.sum. These files change exactly when dependencies change.

For build caches, keys should include the source files that affect the output — typically a hash of the source directory combined with the dependency key.

# GitHub Actions: manual cache with proper key
- uses: actions/cache@v4
  with:
    path: ~/.gradle/caches
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      gradle-${{ runner.os }}-

The restore-keys fallback is important: if no exact key match exists (because the lockfile changed), use the most recent partial match. Starting with the closest available cache is better than starting cold.

Measuring the Impact

Before and after cache configuration, measure the Set up dependencies (or equivalent) step duration across 20 runs. Most teams see a reduction from 2–4 minutes to 5–15 seconds on cache hits. That's the fast path — the optimization that makes every other investment in your pipeline more valuable.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Write a Pull Request That People Actually Want to Review

A well-written PR description reduces review time, improves feedback quality, and gets merged faster — not because reviewers are lazy, but because good context makes good review possible.

Read more

How to Handle a Project That Is Going Off the Rails

Every contractor encounters a project that is going wrong. The difference between engagements that recover and ones that collapse is usually one thing: how quickly someone names the problem.

Read more

Your System Does Not Need to Scale to a Million Users on Day One

Designing for a million users before you have a hundred is not ambition — it is a way of making your system harder to build, harder to operate, and less likely to survive long enough to need that scale.

Read more

Why Oslo Startups Are Using Remote Backend Contractors to Escape Norway's Salary Spiral

Every year the salary expectation goes up. Every year your runway gets shorter. At some point the maths stops working — and you need a different equation.

Read more