Skip to content
Latchkey
Published June 2026 by Kaveh Alemi

The State of Scala CI 2026

The compile-time tax: sbt warmup, the JVM startup penalty, incremental compilation that only survives if the cache does, and the dependency resolution nobody measures.

61%
of Scala CI wall-clock spent in compilation, not running tests
Latchkey analysis (modeled)
4.7x
cold compile vs warm incremental compile on a multi-module sbt build
Latchkey analysis (modeled)
52s
median JVM plus sbt warmup before the first line is compiled
Latchkey analysis (modeled)

Executive summary

Scala CI is a compile-time story from start to finish. The Scala compiler does heavyweight type inference, implicit resolution, and macro expansion, the JVM needs to start and warm before it executes efficiently, and sbt itself has to resolve dependencies and load the build definition before any real work begins. On a cold runner every one of those costs is paid from zero on every push, even when almost nothing relevant has changed since the last one.

With CI adoption at 76% among professional developers, the Scala question in 2026 is not whether to test on every commit, it is how to stop paying the full cold-build tax each time. Scala shares the Rust problem: the compiler, not the test suite, is the dominant line item, which means the optimization budget belongs at the build cache and the toolchain warmup rather than at the tests. The teams that stay fast are the ones that have internalized that inversion.

This report quantifies where Scala CI minutes go. We look at how compilation, dependency resolution, test execution, and JVM warmup split a billed minute, how much incremental compilation saves when its state survives the runner, why Coursier resolution is a silent recurring cost, and why the fixed warmup penalty is the tax nobody measures. The recurring theme is that the bulk of the cost is the same compile and the same dependency graph rebuilt cold, not your code actually changing.

Three numbers frame the year for Scala teams. Around sixty-one percent of Scala CI wall-clock is spent compiling rather than running tests, which is where the savings live. A cold compile costs nearly five times a warm incremental one on a representative multi-module build, so the gap between cold and warm is the whole game. And the JVM-plus-sbt warmup alone burns the better part of a minute before the first line compiles, a fixed cost paid on every short job.

The opportunity is that almost none of this cost is irreducible. A managed cache that persists the incremental compiler state and the resolved dependency tree across runners turns a cold build into a warm one, and a pre-provisioned runner that keeps the toolchain hot removes the warmup penalty that cold ephemeral runners pay every time. That is where the long-build tax actually gets paid down, and it is exactly the territory where managed runners change the Scala economics.

Where a Scala CI minute goes
Compilation 49%
Dependency resolution 19%
Test execution 21%
JVM + sbt warmup 11%

Modeled split of billed minutes for a multi-module sbt build. · Source: Latchkey analysis (modeled)

sbt compile time, cold vs warm incremental
Cold, clean build188 sGitHub cache96 sWarm incremental51 sManaged cache (Latchk…40 s

Time to compile a multi-module build by cache state. · Source: Latchkey analysis (modeled)

Email me the report

The full report is right here on this page, free. Want the link in your inbox to read later or share, plus new Latchkey reports as they drop? Drop your email and we will send it over.

Sent! Check your inbox for the report link.

No spam. Unsubscribe anytime.

Compilation, not testing, is the long pole in Scala CI

Scala's expressive type system is the source of both its power and its compile cost. Type inference, implicit resolution, and macro expansion are genuinely expensive operations, and they scale with the sophistication of the code rather than its raw line count, so a dense, idiomatic Scala module can take far longer to compile than a much larger codebase in a simpler language. On a cold runner the compiler does all of that work starting from nothing every single time.

This puts Scala in the same camp as Rust and against the grain of the usual CI advice. The test suite is a real but minor share of the billed minute; compilation is the dominant cost. A team that optimizes its Scala test execution and leaves the compiler running cold on every push is polishing the wrong surface, while a team that persists compiler state and lets unchanged modules skip recompilation transforms the pipeline.

The teams that stay fast persist the incremental compiler state so that unchanged modules are not recompiled, turning the dominant cost on the pipeline into a fraction of itself. The chart of where a Scala minute goes makes the priority obvious: nearly half the minute is compilation and another fifth is dependency resolution, so the two cacheable costs together are the majority of the bill and the obvious place to spend the optimization budget.

Incremental compilation only helps if the cache survives the runner

sbt's incremental compiler, Zinc, is genuinely excellent at what it does: given the state of the previous build, it recompiles only what actually changed and reuses everything else. On a developer's laptop this is transformative, because the previous build's state is always sitting right there on disk. The warm incremental compile on a multi-module build lands around fifty seconds against a cold clean build near 188, close to a 4.7x difference.

The problem is that ephemeral hosted runners throw that state away. Each run starts on a fresh machine with an empty workspace, so the incremental compiler has no previous build to diff against and is forced into a full cold compile every time. The excellent incrementality that works so well locally delivers nothing in CI, because the precondition it depends on, surviving state, never holds on a runner that is destroyed after every job.

A managed cache that carries the incremental compiler state across runners is what lets a multi-module build compile warm instead of clean on every push. The difference between the hosted-cache number and the managed-cache number on the chart is exactly the gap between a cache that partially survives and one that reliably does. The lesson is that incrementality is only as good as the persistence underneath it, and on hosted runners that persistence is the missing piece.

Dependency resolution is a silent recurring cost

Coursier resolving and downloading a deep Scala dependency tree is a real chunk of every cold run, and a mature Scala service has a deep tree: the standard library, the effect system, the JSON and HTTP libraries, the test framework, and the transitive closure of all of them. Resolving versions and downloading artifacts for that graph routinely takes over a minute when nothing is cached.

What makes resolution especially wasteful is that, unlike compilation, it produces nothing reusable when it is thrown away. A cold compile at least yields object code that a cache could have kept; a cold resolution just re-downloads bytes that were already downloaded yesterday and the day before. It is pure repetition with no artifact to show for it, which is the worst kind of recurring CI cost.

Caching the resolved tree and the downloaded artifacts across the fleet removes resolution as a per-run cost, taking it from over a minute cold to single-digit seconds warm. The per-runner GitHub cache achieves this only partially, because each ephemeral runner maintains its own cache island that the next runner cannot see, so a fleet-wide shared cache is what reliably keeps resolution warm across every job rather than just the lucky ones that land on a recently used runner.

Dependency resolution time by cache state
Cold, no cache71 sGitHub cache33 sWarm local cache13 sManaged cache (Latchk…7 s

Coursier resolution and download time per run. · Source: Latchkey analysis (modeled)

JVM and sbt warmup is the tax nobody measures

Before a single line of Scala compiles, a sequence of fixed costs has to be paid: the JVM has to start, the JIT has to begin warming, sbt has to load and evaluate the build definition, and the compiler toolchain has to spin up. On a representative build this warmup runs to roughly fifty-two seconds, and it happens on every job regardless of how much code actually changed.

This cost is invisible in most teams' mental model of the pipeline because it does not correspond to any step they wrote. There is no warmup line in the workflow file; the time just disappears into the gap between the runner starting and the first useful log line appearing. On short jobs, where the actual work is small, this fixed warmup can be a surprisingly large share of the total billed minute, sometimes rivaling the compile itself.

Warm, pre-provisioned runners that keep the JVM and the toolchain hot remove the warmup penalty that cold ephemeral runners pay on every job. Instead of starting a cold JVM for each run, a runner that is already warm jumps straight to useful work, which is why the fixed-cost tax that nobody measures is also one of the cleaner wins available to a Scala team once the runner layer is right.

Test execution is real but rarely the bottleneck

Scala test execution is a genuine cost, around a fifth of the billed minute, but it is rarely the bottleneck that teams imagine it to be when a pipeline feels slow. The instinct on a slow Scala pipeline is to look at the tests, because that is where the optimization habits from other languages point, but the clock usually says compilation and resolution are the larger drains.

There is a subtlety worth naming: in Scala the line between compilation and testing is blurry, because the test code itself must compile, and compiling the test sources can cost as much as compiling the main sources. So a chunk of what feels like slow tests is actually the compilation of the test modules, which means the same incremental-compile caching that speeds the main build also speeds the path to running tests.

The practical implication is to keep test optimization in proportion. Parallelizing test execution and avoiding redundant fixtures is worthwhile, but it is a second-order lever behind warm compilation and cached resolution. A team that gets the compile cache and the dependency cache warm will find that the test execution share, already a minority, is rarely what stands between them and a fast pipeline.

Long Scala builds are exactly where managed runners win

Scala CI is long-running, cache-sensitive, Linux CPU work, which is the worst possible fit for paying hosted per-minute rates on every cold build. The load is bursty, so a self-hosted fleet sized for peak compile demand sits idle between pushes while a fleet sized for the average starves builds during peak, and either way the spiky compile-heavy profile wastes capacity that the team is paying for.

Managed runners capture roughly 70% of the hosted cost with zero ops, which matters on a pipeline that burns minutes compiling, but the larger effect is on the minute count rather than the per-minute rate. Because managed runners persist the incremental compile cache and the resolved dependency tree and keep the toolchain warm, they cut both the rate and the number of minutes a Scala build actually needs, and the two savings compound on a compile-dominated pipeline.

For a Scala team without a dedicated platform group to maintain a self-hosted fleet and tune its caches, the managed model is the difference between a CI bill that scales with the codebase and one that scales with the change. The relevant comparison is not instance price versus hosted price; it is the total cost of ownership including the warmup, the cache persistence, and the operations that self-hosting quietly consumes from a team that would rather be shipping product.

  • Scala CI is spiky, compile-heavy Linux work that wastes a self-hosted fleet sized for either peak or average.
  • Managed runners capture roughly 70% of the hosted cost with zero ops, while persisting the compile and dependency caches.
  • Warm caches plus a hot toolchain cut both the per-minute rate and the minute count, and the two savings compound.
Hosted runner cost per minute by platform
Linux 2-core$0.008Windows 2-core$0.016macOS$0.08Managed (Latchkey)$0.0025

Published GitHub-hosted rates vs a managed alternative. · Source: GitHub Actions pricing + Latchkey rates

What the fastest Scala teams do differently

The Scala teams with the fastest pipelines treat the compile cache and the dependency cache as first-class infrastructure rather than incidental optimizations. They persist the Zinc incremental state and the Coursier cache across runs, they keep the toolchain warm so the JVM is not started cold on every job, and they scope their cache keys carefully so the cache neither serves stale state nor misses on every push.

They also instrument the pipeline so the invisible costs become visible. They track compile time, resolution time, warmup time, and cost per merge over time, and they treat a regression in any of them as a bug rather than as background noise. Because the warmup tax and the cold-resolution cost are exactly the kinds of waste that hide between log lines, measuring them is what makes them addressable.

  • Persisted Zinc incremental state and Coursier cache, restored warm across runs rather than per-runner silo.
  • A pre-warmed toolchain so the JVM and sbt are not started cold on every job.
  • Cache keys scoped to the build definition and lockfile so the cache neither goes stale nor misses constantly.
  • Test execution kept in proportion behind warm compilation, which also speeds compiling the test modules.
  • Tracked compile, resolution, warmup, and cost-per-merge metrics, with regressions treated as bugs.

Recommendations

Persist the incremental compiler state across runs

sbt incrementality only works if the previous build survives, and ephemeral hosted runners throw it away, forcing a cold compile every time. Carry the Zinc state across runners with a managed cache so a multi-module build compiles warm rather than clean on every push, which is the single largest lever on a Scala pipeline.

Cache the resolved dependency tree fleet-wide

Coursier resolution produces nothing reusable when discarded, so a cold run just re-downloads yesterday's artifacts. Cache the resolved tree and downloaded jars across the whole fleet, not per-runner, so resolution drops from over a minute to single-digit seconds on every job instead of only on lucky cache hits.

Keep the toolchain warm to kill the warmup tax

The JVM-plus-sbt warmup is a fixed cost paid before any line compiles, and on short jobs it can rival the compile itself. Use pre-provisioned runners that keep the toolchain hot so each run jumps straight to useful work instead of starting a cold JVM, removing a tax that nobody measures because it hides between log lines.

Keep test optimization in proportion

Test execution is a minority of the Scala billed minute, and part of what feels like slow tests is really compiling the test modules. Parallelize tests where it helps, but do it after the compile and dependency caches are warm, because warm compilation also speeds the path to running the tests in the first place.

Size the runner layer for compile-heavy bursts

Scala CI load is bursty and compile-bound, which wastes a self-hosted fleet sized for either peak or average. An elastic managed layer that scales to the burst, persists the caches, and keeps the toolchain warm cuts both the per-minute rate and the minute count, and the two savings compound on a compile-dominated pipeline.

Outlook

Expect compile and resolution caching to stay the decisive variable in Scala CI economics through 2026 and into 2027. As services grow more modules and deeper dependency trees, the cold-build cost grows with the codebase while the warm-build cost grows only with the change, so the gap between teams that persist their caches and teams that re-pay cold builds widens steadily as projects mature.

The Scala tooling itself continues to improve incrementality and resolution speed, but the structural bottleneck is increasingly the runner layer's ability to keep that incremental state and that resolved tree persistent across ephemeral jobs. That is an advantage that does not depend on the compiler getting faster, which makes it durable: the better the local incremental story gets, the more is lost when a runner throws the state away.

For most Scala teams the practical takeaway is that the long build is not an immovable property of the language, it is a caching and warmup problem with well-understood fixes. Persist the incremental state, cache the dependency tree across the fleet, keep the toolchain warm, and let an elastic runner layer absorb the bursts, and the compile-time tax becomes a predictable, modest cost rather than a recurring drain on every push.

Methodology

This report combines public Scala ecosystem signals (sbt and Coursier behavior, Scala release cadence, and published GitHub-hosted runner pricing) with Latchkey runner analysis of multi-module sbt builds. Modeled figures reflect a representative multi-module Scala service with an incremental-compile workflow, and are intended to show direction and magnitude rather than a precise population value. The CI adoption headline is from the Stack Overflow Developer Survey. Figures labeled "modeled" are illustrative estimates derived from public pricing and typical pipeline shapes, not a primary survey; figures attributed to a named source reflect that source. Pricing reflects published rates at time of writing and should be verified against current provider pricing.

Sources

More Latchkey reports

See what you would save with Latchkey managed runners and self-healing. Start free → 30-day trial · No credit card