← Back to list

How a Hidden Lombok + Hibernate Interaction Turned a 300ms API into a 30-Second Bottleneck

Sometimes, the most expensive performance problems are not caused by complex algorithms, massive infrastructure issues, or database limits.

Batuhan Biter · 2026-05-20 13:14 · 14 claps · 3.7 min read
#spring #spring-boot #java #jpa #spring-data-jpa
Open on Medium ↗
Wiki topics: 💻 · Programming

How a Hidden Lombok + Hibernate Interaction Turned a 300ms API into a 30-Second Bottleneck

Sometimes, the most expensive performance problems are not caused by complex algorithms, massive infrastructure issues, or database limits.

Sometimes, they come from one overlooked method generated automatically by a library.

This is a story about how I investigated a slow authorization flow in a high-throughput financial application and reduced one critical API response time from around 30 seconds to 300 milliseconds.

The fix was small.

The impact was not.

The Problem

We had a merchant authorization flow where one of the API calls was taking far longer than expected.

Under certain conditions, the response time was around 30 seconds.

This was not acceptable for an authorization-related workflow. The endpoint was part of a core flow, and every delay had a direct impact on user experience, operational efficiency, and platform reliability.

At first glance, nothing looked obviously wrong.

The business logic was not unusually complex. The database was not down. The application was not under extreme load. The code path looked normal.

But the response time told a different story.

First Investigation: Look at the Queries

My first step was not to guess.

I enabled detailed logging around the persistence layer, including Hibernate and SQL-level logs, to understand what the application was actually doing behind the scenes.

That immediately changed the investigation.

Instead of seeing one clean authorization query, I started seeing a large number of unexpected queries being triggered while loading related domain objects.

This had the classic shape of an N+1 query problem.

The application was not just loading the data required for the authorization decision. It was also touching a lazily-loaded collection that should not have been involved in that path.

The Entity Relationship

The domain model had an entity similar to this simplified example:

@Entity
public class Group extends Authority {

    @ManyToMany(fetch = FetchType.LAZY)
    private Set<User> users = new HashSet<>();

    private String name;
    private GroupStatus status;
}

The important part was this:

@ManyToMany(fetch = FetchType.LAZY)
private Set<User> users = new HashSet<>();

The users collection was intentionally lazy.

It was not needed during the authorization flow.

So why was Hibernate loading it?

The Hidden Trigger: Lombok-generated equals/hashCode

The issue was not in the query itself.

It was not in the service layer either.

The problem came from generated methods.

The entity was using Lombok:

@Data
@EqualsAndHashCode(callSuper = false)
@ToString(callSuper = false)
@Entity
public class Group extends Authority {
    ...
}

Because the users collection was not excluded from equals() and hashCode(), Lombok included it in the generated implementation.

That meant whenever the entity was compared, placed into a collection, logged, or processed in a way that triggered equals() or hashCode(), the lazy users relationship could be accessed.

And accessing that collection caused Hibernate to initialize it.

That triggered extra queries.

In this case, a lot of extra queries.

The lazy relationship was not lazy anymore in practice.

The Fix

The fix was small but important:

@EqualsAndHashCode(callSuper = false, exclude = "users")
@ToString(callSuper = false, exclude = "users")

By excluding the users collection from Lombok-generated equals(), hashCode(), and toString(), the authorization flow stopped accidentally initializing the lazy relationship.

The result was immediate.

The API response time dropped from around:

30 seconds → 300 milliseconds

That is roughly a 100x improvement.

Why This Happens

This type of issue is easy to miss because the code looks harmless.

@Data is convenient.

Lazy loading is familiar.

Entity relationships are normal.

But combining them without care can create hidden performance traps.

In JPA/Hibernate applications, entity methods such as:

  • equals()
  • hashCode()
  • toString()

can accidentally trigger lazy-loading if they reference relationships.

This becomes especially dangerous when those relationships are large collections, such as:

  • users in a group
  • roles assigned to users
  • permissions attached to authorities
  • transactions attached to accounts
  • merchants attached to organizations

The problem is not always visible in the service code.

The service may look clean, but generated methods may still touch the wrong fields.

Lessons Learned

1. Do not blindly use @Data on JPA entities

@Data generates more than getters and setters.

It also generates equals(), hashCode(), and toString().

That can be risky for entities with relationships.

For JPA entities, I prefer being explicit:

@Getter
@Setter
@NoArgsConstructor

Then define equals() and hashCode() intentionally.

2. Exclude lazy collections from generated methods

Lazy collections should usually not be part of equals(), hashCode(), or toString().

Especially not in high-throughput systems.

A small generated method can accidentally become a database query generator.

3. SQL logs are still one of the best debugging tools

When performance does not make sense, logs can quickly reveal what the application is really doing.

In this case, enabling Hibernate and SQL logs made the issue obvious.

Without query-level visibility, the investigation would have taken much longer.

4. Performance fixes are not always big rewrites

The solution was not a new database.

It was not a cache layer.

It was not a full refactor.

It was understanding the interaction between Lombok, Hibernate, lazy loading, and entity design.

That is the kind of detail that can decide whether an API takes 30 seconds or 300 milliseconds.

Final Thoughts

This experience reinforced an important lesson for me:

In backend systems, performance problems often live at the boundary between frameworks.

Hibernate was doing what it was asked to do.

Lombok was doing what it was asked to generate.

The database was responding to queries.

The problem was the interaction between all of them.

A small annotation change removed an accidental lazy-loading trigger and turned a slow authorization flow into a fast one.

When working with JPA entities, especially in financial or high-throughput systems, generated methods should never be treated as invisible implementation details.

They are part of the runtime behavior of your system.

And sometimes, they are the bottleneck.


메타데이터
post_id
a889f53689e8
slug
how-one-lombok-annotation-reduced-an-api-response-time-from-30-seconds-to-0-3-seconds-300ms-a889f53689e8
url
https://medium.com/@batuhanbiter/how-one-lombok-annotation-reduced-an-api-response-time-from-30-seconds-to-0-3-seconds-300ms-a889f53689e8
canonical_url
https://medium.com/@batuhanbiter/how-one-lombok-annotation-reduced-an-api-response-time-from-30-seconds-to-0-3-seconds-300ms-a889f53689e8
author_url
https://medium.com/@batuhanbiter
status
ok
fetched_at
2026-06-11 17:15:47