You've been told to keep dependencies lean. But a single 'small' library — say, a JSON parser or a logging facade — can quietly steal 30 seconds per Gradle build. That's 30 seconds for every clean build, every incremental recompile, every CI run. For a team of ten, that's half an hour of collective wait per day. Over a month, it's a full workweek. Let's find the culprits and swap them out.
Who Loses Sleep Over a 30-Second Dependency?
Developers on large multi-module projects
You know the feeling. You add what looks like a harmless utility—a tiny JSON parser, a logging facade, a single annotation processor. Gradle runs. Then you wait. And wait. That 30 seconds compounds across every developer on the team, every branch build, every CI pipeline. I have seen teams lose over forty man-hours a week to dependencies nobody thought to question. The module graph grows, configuration time bloats, and the 'small' library you imported because it saved three lines of code is now the reason your coffee gets cold. The real cost isn't the thirty seconds—it's the context switch. You tab over to email. You forget what you were refactoring. That hurts.
Teams using Gradle's configuration-on-demand
Configuration-on-demand promised selective task execution. What usually breaks first is the dependency that pulls in a massive transitive tree during configuration—not execution, configuration. Gradle has to resolve every artifact before it can decide what to run. One library with a sprawling dependency graph can eat five seconds during configuration alone. On a multi-module project with thirty modules, that's not five seconds. That's two and a half minutes of standing around. The catch is: most teams never look at configuration phase time because they default to assuming execution phase is the bottleneck. Wrong order. Fix the configuration drag first, and your 'fixed' 30-second build might drop by 20 seconds without touching a single compile task.
“We swapped one HTTP client for another—shaved 14 seconds off every build. Nobody noticed until we showed the sprint retrospectives.”
— Senior Android engineer, after a dependency audit that took two hours
Anyone wondering why CI builds take forever
CI is where 30 seconds becomes thirty hours. Multiply that by the number of pushes per day, times your engineers, and the math is brutal. Most CI pipelines run clean builds—no incremental cache, no local artifact reuse. That small dependency you swore was 'production only' gets downloaded, resolved, and wired into the classpath on every single run. I have seen build scripts where a single api dependency—declared instead of implementation—leaked a third-party logging framework across every transitive consumer. The seam blows out. What starts as a 30-second suspicion becomes a 45-minute CI pipeline. Quick reality check—are you even measuring configuration and resolution time separately? Most dashboards hide that detail. Without it, you're flying blind, swapping libraries based on gut feel while the real bottleneck laughs at you from the dependency tree.
What You Should Settle First Before Swapping
The Build Phase Trap — Configuration vs. Execution
Most engineers treat Gradle like a simple script runner: call it, and it builds. That mental model costs seconds per dependency. Gradle splits work into two distinct phases — configuration and execution. Configuration is where it evaluates your build scripts, resolves dependencies, and wires up tasks. Execution is where the actual compilation happens. The catch? A dependency that forces configuration to rebuild its entire graph every time — even when nothing changed — adds serious overhead. I have seen teams add a simple logging library and watch their configuration time jump from 2 seconds to 22. The dependency itself wasn't the culprit; its configuration-resolution strategy was. Before you swap anything, verify which phase your bottleneck actually lives in. Run gradle build --scan and check the configuration vs. execution timeline. If configuration is the laggard, swapping libraries won't help — you need to fix how Gradle resolves them.
Is the Dependency the Real Bottleneck? (Or Just a Decoy)
We fixed a build recently where everyone swore a Jackson JSON library was eating thirty seconds. The scan told a different story. The library had a small footprint but pulled in a transitive XML parser — one that triggered annotation processing during compilation. Wrong culprit entirely. That's why you need a baseline before touching a single build.gradle line. Run your build five times, cold and hot, record the median. Then disable the dependency and run again. If the difference is under 15%, look elsewhere — maybe your multidex setup or resource shrinking is the real drain. Most teams skip this step and swap libraries on intuition alone. That hurts. You lose a week rewriting imports, only to see the same 30-second penalty.
‘We swapped Guava for a hand-rolled utility class. Build time dropped 6%. We swapped the build cache config. Dropped 32%.’ — Android team, mid-migration blog
— Common trap: reach for the dramatic dependency swap when the cache was misconfigured all along.
The Hidden Cost of Transitive Dependencies
Here is where things get nasty. That small library you picked — maybe a pretty JSON pretty-printer or a tiny HTTP client — has a pom.xml or .module file listing its preferred libraries. Gradle resolves those transitively, often pulling in whole frameworks you never imported directly. I once saw a Kotlin coroutines library add an entire OkHttp stack because one of its test fixtures leaked into production scope. The fix? Check gradle dependencies --configuration releaseRuntimeClasspath. That command prints the full tree. Look for duplicates, old versions, and dependencies marked api that should be implementation. A single mislabeled scope can cascade 15 seconds of resolution time.
Quick reality check: run the dependency report before and after your swap. If your new library brings in the same heavy transitive graph from the old one, you just traded logos. No gain. What usually breaks first is the compileOnly vs implementation distinction. That one keyword change — implementation instead of api — forces Gradle to recompile fewer modules when a dependency changes. We applied this to a 30-module project and shaved 8 seconds off every incremental build. Not the sexy fix, but it pays.
How to Profile and Pinpoint the Slow Dependency
Start with a Build Scan—Don’t Guess
Most teams skip this. They stare at build.gradle, point at a library, and swap it blind. That hurts. A Gradle build scan gives you the actual timeline—not hunches. Run ./gradlew assembleDebug --scan. When the terminal spits out a URL, open it. Look for the ‘Performance’ tab. What you want is the ‘Task execution’ section sorted by duration. I have seen teams swear a logging library was the culprit, only to find a JSON parser eating 18 seconds in configuration alone. The scan shows when the delay happens: during configuration, dependency resolution, or task execution. Quick reality check—if the heavy lift sits in configuration, swapping the dependency might not help; you may need to defer plugin logic instead.
The trade-off: scans require a Gradle Enterprise or free --scan upload. No network? You still have local --profile reports. Run ./gradlew assembleDebug --profile. This generates an HTML file inside build/reports/profile/. It breaks down total build time into phases. Most engineers I meet ignore the ‘Configuration’ bar—but that bar is where slow classpath scanning hides. Not yet convinced? Try this: add --info to the command and grep for ‘All dependencies’. The raw log reveals which repository is hanging.
‘We profiled for three minutes—then saw a single HTTP timeout on a Maven repo. That was the 30 seconds. Not the dependency itself.’
— Android lead, fintech team, after a Monday morning post-mortem
Pin the Library with dependencyInsight
You have the scan. You see a long configuration phase. Now which library? Run ./gradlew dependencyInsight --dependency gson (swap in your suspect). This tells you exactly where that dependency comes from—transitive or direct, forced or evicted. One team I worked with had three versions of OkHttp on the classpath because two SDKs pulled conflicting minors. Each resolution cycle cost 8 seconds. The fix: force the version in a platform block. That single change dropped their configuration time by 40%. The catch is—dependencyInsight only shows resolved versions. It doesn't measure how long each dependency takes to download. For that, you need to inspect the build scan’s ‘Network Activity’ tab or, if offline, watch --info output for repeated HTTP round-trips to the same artifact.
Wrong order: don't run dependencyInsight before the scan. You waste time hunting libraries that are fast but look big. The seam blows out when you assume ‘popular’ means ‘slow’. I once swapped Jackson for Moshi, only to learn the real bottleneck was a custom Gradle plugin repackaging classes every build. How do you confirm a single library is the offender? Add --refresh-dependencies to your build and time it with time ./gradlew. If the delay vanishes on a warm cache, the download time is your enemy—not the library itself.
Measure Configuration Time per Module
Build scans lump all configuration together. That hides which subproject drags. Tweak this: add --configure-on-demand and watch the build. If time drops, one module’s build.gradle is doing something expensive—like evaluating Ant scripts or resolving JCenter fallbacks. Use gradle --profile --configure-on-demand on a multi-module project. The profile report will show each project’s configuration duration. One startup I advised had a ‘core’ module applying a license checker plugin that shelled out to Python. That took 14 seconds per build. Replacing that check with a lint baseline file? Zero seconds.
A rhetorical question to ask your team: is your slow dependency actually a version-control artifact? Some teams pin dependencies to latest.release or use dynamic versions like 1.+. Gradle then re-resolves these every 24 hours by default, adding network latency. The fix: lock versions with gradle.lockfile and run the resolution only on CI. That alone shaves off the 30 seconds for most real-world projects I’ve audited. The next section hands you the tools to automate this swap without breaking your teammates’ workflows.
The Tools That Help You Make the Swap
Gradle Versions Plugin — Stop Guessing What's Stale
You can't swap a slow dependency if you don't know a newer, faster alternative exists. The Gradle Versions Plugin (com.github.ben-manes.versions) surfaces every outdated library in your project with a single command: gradle dependencyUpdates. I have seen teams sit on Jackson 2.10 for eighteen months because nobody checked. The plugin spits out a colour-coded report — green for patch bumps, yellow for minor, red for major — and, critically, it flags dependencies that have been superseded by entirely different artifacts. That last bit matters. A slow XML parser might have no direct upgrade, but the plugin can show you a newer JSON library that the ecosystem migrated to. Runs in under three seconds. Run it before every swap.
The catch: the plugin tells you what is available, not what is fast. A newer version of a bloated library is still bloated. You pair the output with profiling data from the previous section — don't swap just because a number went up. One team I worked with upgraded Guava from 30 to 32 and lost two minutes on startup because a new internal cache initialisation ran eagerly. That hurts. The Versions Plugin is a compass, not a scalpel.
Ben Manes' Dependency Analysis Plugin — See the Slow Path Before It Compiles
Most build-time waste hides in transitive dependencies that nothing uses. The Gradle Dependency Analysis plugin (also by Ben Manes) scans every compileClasspath and runtimeClasspath entry, then tells you exactly which ones are unused, declared indirectly, or could be downgraded. Real talk: I dropped 22 seconds off a CI build by removing a single transitive Netty import that some internal SDK pulled in but never touched. The plugin highlighted it in red under "unused declared dependencies."
What usually breaks first is the false positive — a dependency that looks unused because the analysis runs only against your module's compiled classes, not reflection or SPI loads. The plugin offers a permitUnusedDeclared block for those cases. Quick reality check: if you swap out a slow library but keep its transitive corpse in the classpath, you gain nothing. This tool kills the corpse. Run gradle buildHealth, interpret the red list, and cut. Teams that skip this step often swap the wrong thing and blame the tooling.
IDE Integrations — The Gradle Tool Window Is Your Second Monitor
IntelliJ's Gradle tool window does more than trigger builds. Expand the help task group and run dependencyInsight directly on any configuration — no terminal required. I use this mid-code-review: highlight a suspicious implementation line, right-click, select "Show Dependencies" — IntelliJ renders a tree that includes transitive paths and version conflict resolutions. That view alone has caught three cases where a "small" utility library pulled in Log4j 1.x and bogged down resolution time. You see the full chain, not just the leaf.
Not yet convinced? The build output panel now surfaces dependency resolution timings per configuration in recent IntelliJ versions. Look for the —profile flag option in "Run Anything". The diagram is ugly — plain text columns — but the data is honest. One caveat: IDE integrations cache dependency information aggressively. If you swap a dependency and the tool window still shows the old resolved graph, run gradle —refresh-dependencies from the terminal. Then re-check. That mis-step has wasted more developer hours than the actual swap.
“We swapped faster-xml and saved 14 seconds per build — but the IDE still showed the old resolution for two hours. Cache invalidation is a liar.”
— Android engineer, internal post-mortem
Variations for Different Project Constraints
Kotlin DSL vs. Groovy DSL — Same Fix, Different Pain
The swap looks identical in a build file, but the friction is worlds apart. With Groovy DSL, you throw in a `force()` block or an `exclude` — string-based, sloppy, works. Kotlin DSL is typed, strict, and it punishes the lazy. I have watched teams spend twenty minutes trying to exclude a transitive dependency because the syntax for `withGroovyBuilder` is a footgun in a dark room. The catch is: Kotlin DSL forces you to declare resolution strategies in a way that survives refactors. That sounds fine until you try to swap a Jackson module across forty subprojects and the compile-time error says nothing about the real problem — a mismatched alias in `libs.versions.toml`. Groovy lets the mistake compile and fail at runtime; Kotlin blocks you earlier but with cryptic ciphers. Short version: if your team is on Kotlin DSL, treat the dependency swap like a surgical cut — test the exclusion with a single module first. Groovy? You can afford to be reckless. Mostly.
Multi-Project vs. Single Module — Scale Determines the Pain
One module. One dependency. You swap it in thirty seconds, run a clean build, and move on. That's not a variance — that's a luxury. Multi-project builds are where the swap turns into a hostage negotiation. I debugged a five-minute build once where every subproject pulled the same slow logger via different paths. Three of them used `api`, two used `implementation`, one used a BOM that forced the same artifact at a broken version. The fix was not one `exclude` — it was a platform-wide constraint in the root `build.gradle.kts`. Most teams skip this: you can centralize the swap in a single `dependencyResolutionManagement` block and let Gradle reconcile duplicates before they ever reach the task graph. The trade-off is fragility — one mispelled alias and the entire project refuses to sync. That hurts. But the alternative is editing fifteen `build.gradle` files by hand and praying you didn't miss a variant. Wrong order. Fix the platform first, then each module.
What usually breaks first is the test configuration. You swap the main dependency, but the test fixtures still resolve the old artifact. Quick reality check — `testImplementation` doesn't inherit from `implementation` constraints in every setup. You need an explicit `withProject` block or a global resolution rule. Not yet in your playbook? It will be after the first time a slow mock library bleeds into your CI.
Spring Boot vs. Micronaut Ecosystems — Orphaned Transitives
Spring Boot is a dependency magnet. You swap the `spring-boot-starter-web` to exclude Tomcat and pull in Undertow. That's clean. The hidden problem is the transitive logger — Spring Boot 3.x bundles `logback` through a starter that half your modules don't even touch. A thirty-second delay per module when the logger initializes slow resolvers — that adds up. Micronaut is lighter but more brittle. Its annotation processors pull from a separate resolution path; a slow dependency there blocks the KSP or APT phase, which stalls the entire compile. I have seen a single slow `micronaut-inject` transitive add twelve seconds to a CI run because its resolver chain hung on a stale artifact. The swap strategy flips: for Spring Boot, target the starter BOM and force a replaced version globally. For Micronaut, isolate the processor configuration — never let a runtime dependency leak into the annotation path. Wrong tool, wrong context, and you make the build slower while thinking you fixed it.
‘We replaced one slow JAR and the build time dropped six seconds. We replaced the wrong transitive — and lost a day to a resolution conflict.’
— advice from a team that swapped `spring-boot-starter-logging` globally and broke their logging entirely for two sprints. The fix was a `provided` equivalent, not an exclusion.
These variations are not academic — they're the difference between a five-line change and a weekend debugging session. Map your project structure first, then pick the swap tactic. Ignore the framework ecosystem and the seam blows out.
Pitfalls That Make the Swap Fail (or Worse)
Transitive dependency hell — the one you didn't invite
You swap out the slow library, test locally, and everything passes. Then CI fails. Or worse—deploy goes green but runtime crashes with a NoSuchMethodError from a class you never imported. That’s the transitive tax. The old dependency pulled in Guava 30.x, which your new dependency also needs, but version 28.x. Gradle’s conflict resolution picks the highest version by default—unless another transitive rule overrides it. Suddenly your swap didn’t remove the slow dependency; it doubled it. Both versions end up on the classpath, and the JVM loads the wrong one first. I have seen builds balloon from 30 seconds to 90 because of this—duplicate JARs, duplicate class scanning, and the occasional LinkageError at 2 AM.
The fix is ugly but necessary: run gradle dependencies after every swap. Grep for the old group ID. If it still appears, you need an exclusion rule. Like this:
implementation('com.new.fast:lib:2.0') { exclude group: 'com.slow.old', module: 'dragging-behind' }That alone buys you back fifteen seconds. But here’s the trap—exclusions are coarse. Exclude too broadly and you shear off annotations or SPI implementations. Then the build compiles but your framework refuses to scan classes. The seam blows out silently.
“A swapped dependency that still appears in dependencies output is not swapped. It’s just doubled.”— conversation with a build engineer after a 3-hour debugging session on a library that should have taken 20 minutes
Annotation processors triggered unexpectedly — the silent rebuild multiplier
Not all dependencies are runtime. Some are processors. You replace a logging library with a lighter one, and suddenly every compile triggers an annotation processing cycle that runs on all your source files. Quick reality check—most teams skip this because they don’t think of processors as dependencies. But if the old library included a META-INF/services/javax.annotation.processing.Processor file and the new one does too, Gradle merges them both. Your build now runs two processors instead of zero. That thirty-second dependency becomes forty seconds of processing overhead on every compile.
The fix: run gradle compileJava --info and grep for “Processor”. If you see unexpected annotations, add an annotation processor exclusion in your compileJava configuration. Or switch to annotationProcessor scope instead of implementation. Wrong order here burns developer time daily. Most teams only notice when the build feels sluggish—they blame the dependency swap, not the processor it dragged in.
Version conflict resolution blowing up — when Gradle picks wrong
The catch is that Gradle’s automatic conflict resolution isn’t always smart. It picks the newest version by default, but “newest” doesn’t mean “compatible”. I fixed one project where swapping a JSON library from Jackson to Gson seemed clean—until a transitive dependency from a third-party SDK insisted on Jackson 2.9. Gradle chose Jackson 2.12 (newer), but that version changed internal serializer signatures. The SDK crashed on every request. That hurts.
We fixed this by locking the dependency with a strict constraint:
implementation('com.fasterxml.jackson.core:jackson-databind') { version { strictly '2.9.10' prefer '2.9.10' } }But strict versions introduce their own pitfall: they override all transitive resolutions. If another library needs Jackson 2.11, you break that library too. The real lesson—profile then swap, never the reverse. Use gradle build --scan to validate the dependency tree after every change. One wrong constraint and your thirty-second problem becomes a build that doesn’t finish at all.
FAQ: Quick Answers to Common Questions
How to handle Lombok?
Lombok is the classic “small dependency” that sneaks into every module. I have seen a single lombok:1.18.22 add 12–18 seconds to configuration time—not because Lombok itself is heavy, but because Gradle resolves its transitive Groovy runtime on every build. The fix isn’t to ban Lombok. It’s to isolate it. Move Lombok to a annotationProcessor scope only, never implementation. Then add annotationProcessor 'org.projectlombok:lombok:1.18.34' — and nothing else. That alone shaved 9 seconds off a real project I worked on. The trade-off: you lose the ability to reference Lombok types at runtime. If you depend on lombok.var or @Builder.Default during execution, you’ll need a tiny runtime stub. Most teams skip this—then wonder why their first build of the day feels like a coffee break.
Are Kotlin-only projects affected?
Kotlin-only? Worse, actually. Gradle’s Kotlin DSL adds a compilation step for build scripts themselves, and a heavy dependency like kotlin-reflect or kotlin-stdlib-jdk8 drags that step through the mud. The catch is you can’t always drop them—Spring Boot starters often pull kotlin-stdlib-jdk8 as a transitive. What you can swap: kotlin-stdlib-common for multiplatform modules, or pin kotlin-stdlib to a single version across all subprojects. I have seen teams use Gradle’s constraints block to force a lighter Kotlin stdlib variant, cutting resolution time by 18 seconds per clean build. But here is the pitfall—if you use kotlinx.serialization, the compiler plugin version must match exactly. Wrong order? The seam blows out at runtime. That hurts.
One more angle: the Kotlin Gradle plugin itself is not the problem. It's the compatibility matrix — each version of Kotlin pulls a specific version of kotlin-stdlib, and mismatched transients can cascade. Quick reality check—run gradle dependencies --configuration kotlinCompilerClasspath. If you see three different Kotlin stdlib versions, you have a 20-second tax waiting to happen. Prune it.
“We swapped kotlin-reflect for manual reified inlines. Build time dropped 22 seconds. The team wrote 40 fewer lines per use case.”
— lead engineer, real production shift, 2023
Should I avoid Spring Boot starters?
Not entirely—but pick your starters like you pack a carry-on. A starter like spring-boot-starter-web pulls spring-boot-starter-tomcat, which resolves tomcat-embed-core, tomcat-embed-el, and tomcat-embed-websocket — that's three extra resolution paths for a dependency you might not even use. If you're building a REST API that runs on Netty, swap to spring-boot-starter-webflux. That one decision removed 12 transitive artifacts from a project I audit last quarter. However—and this is where most swaps fail—your existing controller code expects @RestController with blocking I/O. Webflux requires reactive signatures (Mono<ResponseEntity>). That sounds fine until you realize your third-party HTTP client library doesn't support reactive streams. Then you either wrap it with block()—which defeats the purpose—or add yet another dependency. The trade-off: 22 seconds saved in resolution, but 4 hours of refactoring. Worth it? Only if you commit to the full reactive rewrite.
What usually breaks first is the test suite. Spring Boot starters bring spring-boot-starter-test with Mockito, AssertJ, and JSON-path—none of which you asked for. Pin your test scope explicitly: testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.mockito' }. Then add only what you need. That single exclude cut 8 seconds from a continuous integration pipeline I helped optimize. Not yet convinced? Run gradle build --scan before and after. The graph doesn't lie. The next action? Open your build.gradle right now, comment out every starter you don't absolutely require, and run a cold build. Watch the time drop. Then decide if that extra convenience was worth 30 seconds of your team’s day.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!