Error Tracking & Monitoring

How to Read a Java Stack Trace (Caused By, Explained)

Java stack traces read bottom-up and hide the real error in "Caused by". Learn to find the root frame, follow nested causes, and cut suppressed noise.

Java stack traces can feel like an archaeological dig — layers of framework code, exception chaining, proxies, and cryptic synthetic method names all conspiring to hide the real culprit. Yet how to read a Java stack trace is one of the highest-leverage debugging skills you can master. A few minutes staring at the trace often saves hours digging through logs.

The biggest gotcha: the root cause isn't at the top where the exception is thrown — it's buried in the last Caused by: block. Most developers miss this inversion and chase phantoms. Once you know where to look and what each frame means, Java stack traces become navigable. Unlike the general guide to reading stack traces, Java's exception chaining, synthetic proxy classes, and the -XX:-OmitStackTraceInFastThrow JVM optimization create unique traps you need to know.

The anatomy of a Java stack trace

A Java stack trace has five key parts: the exception class, the message, the stack frames, exception chaining via Caused by:, and sometimes ellipses that hide repeated frames. Let's look at a real example from a Spring Boot app:

java.lang.RuntimeException: Failed to fetch user data
    at com.example.auth.UserService.getUserData(UserService.java:42)
    at com.example.auth.UserService$$FastClassBySpringCGLIB$$1234abcd.invoke(UserService.java)
    at com.example.api.AuthController.login(AuthController.java:15)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:207)
    ... 23 more
Caused by: java.sql.SQLException: Connection refused
    at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:862)
    at com.mysql.cj.jdbc.ConnectionImpl.<init>(ConnectionImpl.java:436)
    at com.mysql.cj.jdbc.ConnectionImpl.getInstance(ConnectionImpl.java:231)
    ... 5 more
Caused by: java.net.ConnectException: Connection refused (Connection refused)
    at java.base/java.net.PlainSocketImpl.socketConnect(Native Method)
    at java.base/java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:399)
    at java.base/java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:265)

Decode it line by line:

  • java.lang.RuntimeException: Failed to fetch user data — the exception type (fully qualified), colon, then the message string you provided. This is what surfaced but not necessarily what caused it.
  • at com.example.auth.UserService.getUserData(UserService.java:42) — your code: package, class name, method name, then file and line. This is where your method was executing when the exception occurred.
  • at com.example.auth.UserService$$FastClassBySpringCGLIB$$1234abcd.invoke(...) — a synthetic class generated by Spring/CGLIB proxying. Ignore the $$FastClassBySpringCGLIB$$ noise; focus on UserService. Spring wraps your bean with proxies for transaction management, AOP, and security.
  • at java.base/java.lang.reflect.Method.invoke(Method.java:566) — framework plumbing. java.base is the module name; the file is in the JDK itself, not your code. Skip these.
  • ... 23 more — the trace stops here because the remaining 23 frames are identical to ones shown in the block above (look for a previous trace segment that has exactly 23 frames). This is a compression trick Java uses to keep traces readable.
  • Caused by: java.sql.SQLException: Connection refused — exception chaining. Your code threw a RuntimeException but wrapped an underlying SQLException. This second block is the root cause.
  • Caused by: java.net.ConnectException: Connection refused... — even deeper chaining. The database couldn't connect because the socket connection was refused. This is the ultimate root cause.

The critical insight: you read the trace backwards. The first Caused by: block shows you what actually failed; the RuntimeException at the top is just your code's reaction to that failure.

Reading order: top to bottom, then bottom to top

When you first see a trace, your instinct is to look at the top. Resist. Here's the algorithm:

  1. Scan the top exception message for context about what was being attempted (e.g., "Failed to fetch user data"). This tells you what operation broke.
  2. Look at the first frame that's your code (not a library, not java.base, not Spring). In the example, that's UserService.getUserData(). This is where the symptom appeared.
  3. Jump to the last Caused by: block. The deepest nested cause is the root problem. In the example: ConnectException: Connection refused. This is why things failed.
  4. Read the root-cause message and its first frame to understand what was actually wrong (database port down, missing network route, credentials invalid, etc.).
  5. Trace backwards up the Caused by: chain to understand how the error propagated. Did your code fail to handle it? Did you wrap it incorrectly? Did you lose context?

The most common mistake: chasing the top exception. If your code threw RuntimeException wrapping a SQLException wrapping a ConnectException, the problem isn't in your RuntimeException handler — it's in the database connection pool or network. Always scroll to the last Caused by:.

Decoding the frame line

Every frame follows this pattern:

at <fully.qualified.ClassName>.<methodName>(<SourceFile.java:lineNumber>)
  • fully.qualified.ClassName — the package and class where the method lives. Example: com.example.auth.UserService.
  • methodName — the method that was executing. Special cases: <init> is a constructor, <clinit> is a static initializer, and lambda$0 is a lambda expression (the number just identifies which lambda in the class).
  • SourceFile.java:lineNumber — the actual file on disk and the line where code was running. Use this to navigate in your IDE.
  • Native Method — no source file; the method is implemented in the JDK in C. Often seen in socket code, file I/O, and thread scheduling. You can't fix native methods, but they show you where your code was blocked.

For synthetic classes generated by proxies or frameworks:

at com.example.UserService$$FastClassBySpringCGLIB$$1234abcd.invoke(UserService.java)

The $$FastClassBySpringCGLIB$$1234abcd is compiler-generated bytecode. Spring creates this wrapper to intercept method calls for transactions and security. Skip the CGLIB noise and focus on the real class name (UserService). If you're seeing NullPointerException or IllegalArgumentException through a proxy, the real error is in your code, not the framework wrapper.

The ellipsis (... N more) explained

When you see:

    ... 23 more

It means: "The next 23 frames are identical to frames we already showed you higher up in this same trace block." Java uses this to avoid printing the same call chain twice (common in loops or recursive calls). If you really need to see those frames, look at a trace without the suppression — most error trackers let you expand them.

Exception chaining and wrapped exceptions

Java lets you nest exceptions:

try {
  database.connect();
} catch (SQLException e) {
  throw new RuntimeException("Failed to fetch user data", e);
}

The trace captures both:

java.lang.RuntimeException: Failed to fetch user data
    at com.example.auth.UserService.getUserData(UserService.java:42)
Caused by: java.sql.SQLException: Connection refused
    at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:862)

The Caused by: means the SQLException was the cause parameter passed to the RuntimeException constructor. Exception chaining loses nothing — you get the full trace — but it reads confusingly because the outer exception is a wrapper.

Suppressed exceptions (try-with-resources)

When you use try-with-resources:

try (Connection conn = dataSource.getConnection()) {
  // work with conn
} catch (Exception e) {
  // Exception closes conn; if close() also throws, it's suppressed
}

If the body throws one exception and the resource's .close() throws another, the second one is suppressed:

java.sql.SQLException: Query failed
    at com.example.Query.execute(Query.java:100)
Suppressed: java.sql.SQLException: Error closing connection
    at com.mysql.cj.jdbc.ConnectionImpl.close(ConnectionImpl.java:670)

The Suppressed: block is secondary. Investigate the primary exception first.

Practical traps that hide the real culprit

The -XX:-OmitStackTraceInFastThrow gotcha

This is a production gotcha worth knowing. The JVM has an optimization: if the same exception is thrown repeatedly in hot code (e.g., in a tight loop), the JVM stops recording the stack trace to avoid overhead.

while (true) {
  try {
    risky();
  } catch (SomeException e) {
    // On the 10,000th throw, the trace becomes null!
  }
}

You'll see:

java.lang.SomeException: <no stack trace available>

To disable this optimization and always get a trace (useful in development), use:

java -XX:-OmitStackTraceInFastThrow -jar app.jar

This is most dangerous in production when a repeated exception occurs in a retry loop or under load. The error appears to have no trace, making it look corrupt when it's actually a JVM optimization. If you see &lt;no stack trace available&gt;, this flag might be hiding the real frames.

Async and reactive traces lose context

In Spring Boot error tracking, asynchronous code (async/await, CompletableFuture, Reactor) often shows a truncated trace. The JVM doesn't capture the caller of an async operation — only the async handler's frames. This is especially tricky with threading issues like ConcurrentModificationException where the race condition happens in one thread but you see a trace from another.

CompletableFuture.supplyAsync(() -> database.query())
  .thenApply(result -> process(result))
  .exceptionally(e -> null);

If database.query() fails, the trace shows frames inside the async task but not the caller that submitted it. Use breadcrumbs (manual logging of key events) to bridge this gap.

Proxies and framework code bury your frames

Spring, Hibernate, and servlet containers wrap your code with generated proxies. A stack trace might show 30 frames of Spring internals before reaching your method. Focus on finding your package name and ignore the framework scaffolding. Your first frame is where you can act.

A practical method

When you get a stack trace:

  1. Find the first frame in your package (not java.base, not framework code). This is where your code failed.
  2. Jump to the last Caused by: block. This is where the problem actually is.
  3. Read the root cause's message and first frame. Ask: what was actually failing? Database? File? Network? Validation?
  4. Trace the chain upward to see how it propagated and wrapped. Did you handle it? Did you add context?

Most stack traces are solved in under a minute once you know this pattern.

Beyond the trace

A stack trace shows where the code was. It doesn't tell you why the user hit it, how many are affected, or which release introduced it. That's where error tracking comes in — grouped traces with breadcrumbs, affected user counts, releases, and one-click source links make stack traces actionable rather than archaeological. If your stack trace shows ClassNotFoundException or NoClassDefFoundError, you're seeing a class-loading issue; if it's OutOfMemoryError, you're seeing a resource exhaustion. Both need context beyond the trace.

Start tracking errors in minutes

When a Java exception lands in your error tracker, LightTrace maps each frame to your source, highlights the root cause chain, and links directly to GitHub — so you go from trace to fix in seconds.

Java stack traces are dense but decodable. Master the reading order (top for context, bottom for cause), skip framework plumbing, and focus on frames in your code. With practice, a tangled trace becomes a clear roadmap to the fix.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.