My first serious performance problem was a component in a telecom and CRM platform at FlyTxt that was too slow, and my first instinct was completely wrong. I read the code, found the loops that looked expensive, tightened them, and measured no improvement whatsoever. I had optimised something that was not the bottleneck, which is the default outcome of optimising from inspection.

What eventually produced a 10x latency reduction was learning to profile properly and letting the measurement choose the target. The target turned out to be the ORM's data access, not the logic I had spent two days reading.

Why reading code does not find the bottleneck

Source code shows you complexity. It does not show you cost, and the two correlate badly in any application that talks to a database.

A triple-nested loop over in-memory collections looks alarming and may cost microseconds. A single innocuous line — subscriber.getPlan().getName() — looks free and may cost a network round trip, because it is a lazy association being resolved inside a loop. Nothing in the text of the code distinguishes them. This is why “I reviewed the code and made it more efficient” so often produces no measurable change: the intuitions that work for algorithms do not survive contact with a system whose dominant cost is I/O.

What the profiler actually told me

JProfiler gave me two views that mattered, and the second is the one I had not thought to look at.

The CPU view showed that almost no time was being spent where I expected. The hot methods were framework internals — proxy initialisation, result-set mapping — which is the fingerprint of an ORM doing far more work than the business logic asked for.

The JDBC view was conclusive. It showed the statements actually executed, with counts, and the count was the tell: a single request was issuing hundreds of nearly identical queries that differed only in a parameter. That is the N+1 pattern, and once you have seen it as a number you cannot un-see it.

N+1 queries versus a batched fetch On the left, one query selects a page of subscribers and then one additional query runs per row to resolve its plan, so the number of round trips grows with the result size. On the right, the same data is fetched with one query for the subscribers and one batched query for all their plans, so the round-trip count is constant. Before: one query per row select subscribers select plan for row 1 select plan for row 2 select plan for row 3 … once more per row returned round trips grow with N After: batched fetch select subscribers select plans in one query two round trips, any N same rows, same result
Identical output, identical business logic. The only difference is how many times the application crossed the network, which is the only part that was ever expensive.

The Hibernate behaviour I did not understand

The honest diagnosis was that I did not know how the ORM I used daily actually issued queries. I knew lazy loading existed. I had not internalised that lazy is a per-association, per-access decision made at the moment of dereference, which means the query count depends on the shape of the code touching the objects, not on the mapping alone.

Reading Hibernate's user guide properly — the fetching chapter in particular — was worth more than the previous six months of using it. Three things changed how I write persistence code:

  • Fetch strategy is a property of the query, not of the entity. Marking an association eager to fix one slow screen makes every other query that touches the entity heavier. The strategy belongs where the access pattern is known — a join fetch on the specific query that needs the data.
  • Batch fetching converts N queries into a small constant. If lazy loading genuinely is the right model, batch size turns one query per row into one query per batch of rows, which is most of the win for none of the redesign.
  • The generated SQL is the source of truth. Turning on statement logging in development, permanently, is the cheapest performance practice I know. You stop shipping N+1 patterns because you see them the moment you write them.

Worth knowing the boundary between specification and implementation here: the Jakarta Persistence specification defines what LAZY and EAGER mean as requirements, and leaves a great deal of latitude on how a provider satisfies them. Batch fetching is Hibernate's answer, not a portable guarantee — which matters if you ever assume behaviour will carry across providers.

Why it was 10x rather than 20%

Big multiples come from removing a whole class of work, never from making existing work incrementally faster. Tuning a query gets you a percentage. Deleting hundreds of round trips per request gets you a multiple, because the removed cost was not compute at all — it was latency, paid serially, hundreds of times.

That is the general rule I took away. Before optimising, ask whether the target is slow work or repeated work. Slow work rewards tuning. Repeated work rewards restructuring, and the payoff is arithmetically larger.

What I would do differently, knowing what I know now

Measure before forming a hypothesis, not after. I built a theory from reading, then looked for evidence. Reversing that would have saved two days. The profiler was not the last resort it felt like; it was the first step.

Look at the database's own view too. A profiler tells you what the application asked for. EXPLAIN tells you what the database did with it, and the statistics views tell you which statements dominate in aggregate. A query that is individually cheap and executed ten thousand times does not look like a problem from either end alone.

Make the signal permanent. A one-off profiling session fixes one incident. What prevents the next one is having the numbers continuously: Micrometer instrumentation exposing per-operation timings and query counts, so a regression shows up as a changed metric rather than as a complaint. Profilers are diagnostic tools; they are not monitoring, and treating them as such means you only ever find performance problems after someone else does.

Profile on representative data. N+1 is invisible at N=3 and fatal at N=3,000. Every performance bug I have met since was either hidden or created by the difference between a development dataset and a production one.

The durable lesson is not about Hibernate. It is that performance work is a measurement discipline wearing an engineering costume. The engineering part — batch fetching, a join, a restructured query — was an afternoon. Knowing which afternoon's worth of work to do was the whole problem.