You click Checkout. It takes almost a second. Yesterday it took a quarter of that.
The usual next step is to open five tools: the Network tab, the Spring Boot logs, a SQL log, maybe a profiler, maybe Postman. Each one shows you one layer. You become the glue, lining up timestamps and guessing which layer is to blame.
This post shows a different way. We'll trace one click from React, through Spring Boot, down to the SQL, and back to the React state update, then read the trace to find the cause. The tool is Causeline, an open-source (Apache 2.0) debugger I've been building for exactly this stack.
It takes about five minutes to set up.
What you need
- JDK 21+ and Spring Boot 4.1
- React 18 or 19, Node 22
- An app where the React front end calls the Spring Boot API
Step 1: Add the Spring Boot starter
<dependency>
<groupId>dev.causeline</groupId>
<artifactId>causeline-spring-boot</artifactId>
<version>0.1.0-alpha.4</version>
</dependency>
Using Gradle
implementation("dev.causeline:causeline-spring-boot:0.1.0-alpha.4")
Step 2: Switch it on in a dev profile
src/main/resources/application-dev.yml:
causeline:
enabled: true
Causeline is off unless you enable it, and it refuses to start when a prod or production profile is active. That matters, because it captures a lot: headers, bodies and SQL values. It's meant for your laptop and your QA environment.
Using Spring Security?
Permit /causeline/**. Causeline protects those paths with its own access token:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/causeline/**").permitAll()
/* ...your rules... */);
Step 3: Start the app
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
The log prints a link with a fresh access token:
Causeline UI: http://localhost:8080/causeline/?token=...
Open it and call any endpoint. You'll already see server-side traces: controller, repositories, SQL with values, and outbound HTTP calls.
Want your service methods as separate rows? Use Micrometer's standard annotation; there's no Causeline-specific one:
@Observed
public Order createOrder(OrderRequest request) { ... }
Step 4: Add the React SDK
npm install @causeline/react@alpha
import { CauselineProvider } from '@causeline/react';
createRoot(root).render(
<CauselineProvider endpoint="/causeline/api/spans">
<App />
</CauselineProvider>,
);
The provider does nothing in production builds, so it's safe to leave in.
Proxy /api and /causeline to Spring Boot so requests stay same-origin. With Vite:
// vite.config.ts
server: {
proxy: {
'/api': 'http://localhost:8080',
'/causeline': 'http://localhost:8080',
},
},
Step 5: Name the click
Wrap the handler in trace(), and use useTracedState for the state you care about:
import { trace, useTracedState } from '@causeline/react';
const [status, setStatus] = useTracedState('checkoutStatus', 'idle');
const onCheckout = () =>
trace('Checkout', async () => {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(cart),
});
setStatus(response.ok ? 'paid' : 'failed');
});
The React SDK adds a standard W3C traceparent header to the request. Spring Boot picks it up through Micrometer Tracing, so the browser and server spans land in one trace.
Click the button. Open the newest trace.
Reading the trace: why is it slow?
The waterfall reads top to bottom as one tree: the Checkout action, the browser request, the Spring request, OrderController.createOrder, the transaction, OrderService.createOrder, the cache, the repository, the INSERT, the payment call.
Two columns matter:
- Duration is inclusive: a span plus everything under it.
- Self is the time a span spent on its own work, not waiting on its children.
Duration tells you where the time passed. Self time tells you who spent it.
The controller shows 914 ms, but only 8 ms of that is its own. It's waiting. Causeline ranks bottlenecks by self time, so it skips the parents and points at the span doing the work:
Bottleneck: POST localhost/fake-payment/charge · 844 ms · 88%
It also flags a second problem: the database transaction was open for 907 ms, and 844 ms of that was waiting for the payment call. That holds a database connection the whole time. The fix is to make the call before or after the transaction.
One detail I cared about: self time subtracts the union of child intervals. If a span starts three requests in parallel, their overlap is counted once, not three times. Otherwise the ranking would lie whenever things run concurrently.
When it fails: where was it thrown?
When the payment provider times out, the failing path turns red, from the HTTP client up to the controller. Below the waterfall you get the exceptions with their class, message and location in your code (PaymentClient.java:36), and an Open link that jumps to that line in VS Code, IntelliJ or Cursor.
This also works for exceptions your code catches and logs, as long as they're logged at WARN or ERROR with the exception attached. Those are often the ones that hide.
Did the fix work? Replay and compare
After a change, you don't need to click through the UI again. Replay resends the captured request (method, path, headers, body) to your app, and Compare with… puts any two runs side by side:
Every span shows then vs now, and the ones that changed are marked. Here the whole slowdown traces back to PaymentClient.charge; the rest of the request is unchanged. It also tells you when the response body differs.
Bonus: let your coding agent read the trace
Causeline has an MCP endpoint, so Claude Code, Cursor, Copilot and other MCP clients can read the same traces. Your agent can find what failed and where, fix it, then replay the request and compare the runs itself. Causeline runs no AI of its own.
The startup log prints the command. For Claude Code:
claude mcp add --transport http causeline http://localhost:8080/causeline/mcp --header "X-Causeline-Token: <token>"
The agent sees traces the way an export shows them: credentials, sensitive values and SQL literals are redacted. By default it can only replay GET and HEAD requests. Anything else needs an allowlist in your configuration (causeline.mcp.*), which the agent can't change.
What else is in there
- N+1 queries and long transactions are flagged automatically
- Explain a slow SELECT to see its query plan
- Copy as cURL, MockMvc test or k6 script
- OWASP Top 10 hints on the traffic you already captured
- Load replay: send a request many times and see which span grew
How it stays safe
- It runs inside your app at /causeline. No account, no agent to install, no cloud.
- Off by default, and it refuses to start in production.
- The UI and API need an access token.
- Anything leaving the app (exports, bug reports, OpenTelemetry export, MCP) is redacted by default.
- Every kind of captured data can be switched off with causeline.capture.*.
Known gaps
It's an alpha, so here's what it doesn't do well yet:
- A cancelled fetch shows as a failed request (AbortError), not clearly as "cancelled".
- A retry shows as a second request, not linked to the first attempt.
- Exceptions swallowed without logging can't be seen.
- Next.js is supported through @causeline/next, but only on the Node.js runtime, not Edge.
Try it and tell me what broke
https://github.com/causeline/causeline
Website: https://causeline.dev/
If you build with Spring Boot and React, I'd really value your feedback. Try it on a real app and tell me in the comments what broke, or what you expected to see in the trace and didn't.



Top comments (0)