NoSuchMethodError: Fix Java JAR Version Skew
Fix NoSuchMethodError fast: expose the stale JAR with dependency:tree, pin one version, and smoke-test the packaged artifact...
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Maven or Gradle basics
- ✓Classpath concepts
- ✓A JDK plus jar and javap tools
- NoSuchMethodError is a runtime linkage failure: compiled against one library version, running against an older one missing the method
- Confirm with mvn dependency:tree and jar tf to find which JAR actually supplies the class at runtime
- Fix by aligning versions with dependencyManagement or a BOM and evicting the stale duplicate
- Don't confuse it with NoSuchMethodException, the checked reflection error for missing methods by name
Imagine following a recipe that calls for a blender button your old blender lacks — the recipe was written for the new model. Your code compiled against the new library with its new method, but at runtime an older JAR showed up without it. The JVM presses the missing button and fails. The fix isn't rewriting the recipe; it's putting the right blender on the counter — one version of the library, the new one, first on the classpath.
It compiled cleanly. Tests passed. Then production threw NoSuchMethodError on a library call that's right there in the docs — com.example.Client.setTimeout(I)V or similar. The method exists in your IDE, in the Javadoc, in your build. It just doesn't exist in the JAR that actually loaded at runtime, because an older copy of the same library won the classpath race.
This is the signature injury of dependency skew: two versions of one library on the classpath, and the wrong one first. Transitive dependencies drag in old copies silently — your direct dependency wants v3, some other library drags v1, and Maven's nearest-wins rule picks v1. Everything compiles against v3's API and detonates against v1's reality.
This guide gives you the linkage playbook. You'll learn why it compiles yet fails, how mvn dependency:tree exposes the duplicate, how to read which JAR loaded with -verbose:class, how classpath order decides winners, and how BOMs and the enforcer plugin prevent recurrence. Plus the crisp split from NoSuchMethodException so you never chase the wrong one again.
Compiles Fine, Fails at Runtime: the Linkage Gap
Java splits method checking across two moments. At compile time, javac verifies the method exists in the compile classpath's library version — v3 with your new overload. At runtime, the JVM links each call against whatever class file it actually loads, with no recheck against v3. When an older duplicate wins, linking fails and the JVM throws NoSuchMethodError. The compile and the run disagreed about which library exists, and the run always has the last word.
The message is precise once you decode it: com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(Ljava/lang/Object;)[B names the class, method, parameter types in descriptors, and return type. Compare that descriptor against the loaded JAR's javap output — the overload will be absent there and present in the compile version. That comparison is the entire diagnosis; everything else is finding how the stale JAR got in.
The probe below prints where any class actually loaded from — the single most useful linkage diagnostic. Run it in production's JVM with the failing class name and it names the winning JAR outright. Teach every backend engineer this five-liner; it ends classpath arguments in seconds. Teach every backend engineer this five-liner; it ends classpath arguments in seconds instead of hours. Compare the descriptor against the loaded JAR's javap output and the gap stares back at you.
dependency:tree: Exposing the Duplicate
Maven's dependency:tree prints every transitive path to each artifact, and duplicates show as multiple versions with different depths. Nearest-wins mediation picks the shallowest — so when your Jackson 2.17 sits at depth 3 and a metrics SDK's 2.9 sits at depth 2, the build silently runs 2.9. The tree makes this visible: look for the same artifactId with two versions, note the depths, and the winner is the nearer one. Gradle's dependencies task shows the equivalent with its own conflict notation.
Read the tree on every dependency change, not just during incidents. A one-line SDK bump can drag a dozen transitives; the tree diff against main shows exactly what moved. Pipe it to a file and diff: mvn dependency:tree > /tmp/after.txt, then diff /tmp/before.txt /tmp/after.txt. Version downgrades in that diff are rollback candidates before they ever package.
Exclusions and pins are the two repairs. An exclusion removes the stale transitive at its source; a dependencyManagement pin forces one version everywhere regardless of depth. Pins win for critical shared libraries like Jackson, logging, and HTTP clients — declare them once and let mediation argue with the pin instead of your runtime.
Classpath Order: Why Position Decides Truth
The JVM's search rule is brutally simple: first class file found for a name wins, later duplicates are invisible. On a flat classpath, order is truth — lib/a.jar before lib/b.jar means a.jar's classes shadow b.jar's same-named classes entirely. App servers add scoped layers (EAR, WAR, server lib) with parent-first or child-first delegation, each reordering which copy loads. Containers add one more shuffle when layer caching reorders COPY steps. Same code, different order, different winner.
Diagnose order with -verbose:class: it logs each class with its source JAR in load sequence, so the winner is the path printed beside the failing class. For layered servers, print the effective classpath per module and compare against the server's delegation docs. For fat JARs, order collapses into packaging — the shade plugin's include order and relocation rules decide, and only jar tf plus the plugin config reveal the outcome.
The durable fix reduces order's power: one version per library so order stops mattering. Dedupe first with pins and exclusions; manage layering second with server docs; debug order only for what's left. An explicit classpath you generate and log at startup beats a directory glob that reorders with every filesystem.
NoSuchMethodError vs NoSuchMethodException
The names differ by three letters and the universes differ completely. NoSuchMethodError (java.lang, unchecked Error) fires at runtime linkage when a compiled call can't resolve — your call is correct, the loaded library is stale. NoSuchMethodException (java.lang, checked Exception) fires from reflection — Class.getMethod or getDeclaredMethod found no match — meaning your lookup name or signature is wrong. One indicts JARs, the other indicts names.
The handling differs accordingly. The Error never gets caught for recovery — you fix the classpath, rebuild, and redeploy; catching it just postpones the same throw. The Exception is ordinary control flow in reflective code: catch it, fall back to another method, or report the bad name. Conflating them sends engineers editing method names for a JAR problem or rebuilding JARs for a typo.
The snippet shows the reflection side done right: lookup with explicit parameter types, catch the checked exception, fall back deliberately. When this catch fires, the diagnosis is the name or signature — print both, compare against javap of the target class, and fix the lookup. If instead the production trace shows the Error, close the reflection docs and open the dependency tree.
BOMs, Enforcer, and Locks: Prevention That Holds
Prevention means making version choice explicit and conflicts loud. A BOM (bill of materials) import pins a coherent set of library versions — Spring Boot's, Jackson's, AWS SDK's — so transitives align instead of fighting. dependencyManagement entries do the same per artifact for libraries outside any BOM. Together they convert mediation from positional accident to declared intent: the build resolves what you wrote, not what sits nearest.
The enforcer plugin's dependencyConvergence rule turns silent conflicts into build failures: any artifact arriving in two versions breaks the build until someone pins or excludes deliberately. Pair it with dependency:tree diffs in pull-request checks so version moves get human eyes. For Gradle, constraints and resolutionStrategy force rules plus dependency locking serve the same role — lockfiles committed to Git make every build resolve identically.
Add one runtime backstop: a smoke test that exercises serialization, HTTP, and reflection paths against the packaged artifact in CI. Linkage errors only exist in packaged form, so unit tests against compile classpaths can't catch them. The smoke test runs java -jar on the real artifact and fails the pipeline on any linkage Error — the incident in this article would have died there.
Reading the Descriptor and Locking the Fix
The Error's message is a precise fingerprint: com.example.Client.setTimeout(I)V breaks into class, method name, (I) int parameter, and V void return. Map each part: the class names the duplicated library, the method names what the stale copy lacks, the descriptor distinguishes overloads — setTimeout(I) missing while setTimeout(J) exists is still this Error. Compare with javap on both JARs and the gap stares back at you.
Lock the fix three ways. Pin the version in dependencyManagement so mediation can't drift it. Add the exclusion that removes the stale transitive at its source. Add the smoke test that calls the exact method against the packaged artifact — named for the incident, like serializesWebhookPayload, so history travels with the test. Any one lock can slip; three hold.
Record the conflict in the pull request: which library dragged the stale copy, what depth won, and what pin fixed it. The next engineer with a metrics-SDK bump reads that note and checks the tree before merging. Version-skew knowledge compounds across incidents until the build rules encode all of it. Version-skew knowledge compounds across incidents until the build rules encode all of it for future upgrades. Record the conflict source in the pull request so the next engineer checks the tree before merging.
Stale Jackson 2.9 Shipped With Build, Broke 100% of Webhooks
- Nearest-wins mediation picks versions silently — declare every critical library in dependencyManagement so choice becomes explicit, not positional.
- Fat JARs hide duplicates as older bytes, not extra files. Smoke-test the packaged artifact, not just compiled classes, for linkage errors.
- Unrelated-looking upgrades deserve a dependency:tree diff. The metrics SDK never mentioned Jackson in its changelog, but its tree did.
| File | Command / Code | Purpose |
|---|---|---|
| io | public final class ClassOrigin { | Compiles Fine, Fails at Runtime |
| io | public final class DepVersions { | dependency |
| io | public final class ReflectLookup { | NoSuchMethodError vs NoSuchMethodException |
| io | public final class LinkageSmoke { | Reading the Descriptor and Locking the Fix |
Key takeaways
Common mistakes to avoid
6 patternsEditing code for a linkage Error
Rolling back your own library declaration
Testing only compile classpaths
Globbing lib directories onto classpaths
Catching the Error for recovery
Ignoring tree diffs on SDK bumps
Interview Questions on This Topic
What causes NoSuchMethodError at runtime?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Exception Handling. Mark it forged?
5 min read · try the examples if you haven't