One Page, Four Hundred Queries

I remember sitting in a dimly lit office at 2:00 AM, watching a dashboard turn a violent shade of red while a legacy payment service choked on its own breath. It wasn’t a sudden crash or a loud error; it was the slow, agonizing death of a thousand tiny cuts. I was debugging n plus one queries in a system that had worked perfectly in staging, only to find that real-world data volume had turned our elegant ORM into a machine gun of redundant SELECT statements. Most tutorials treat this like a simple configuration tweak, but when you’re staring at a log file that’s scrolling faster than you can read, it feels a lot more like a crime scene.

I’m not here to give you a sanitized walkthrough of the “happy path” or suggest you just add a single line of magic code. Instead, I want to show you what the wreckage actually looks like when these patterns hit production. We are going to look at how to spot the silent latency creep before it hits the database, how to interpret the noise in your logs, and how to actually fix the underlying architectural rot. No hype, no fluff—just the parts the documentation usually forgets to mention.

Table of Contents

The Trap of Lazy Loading vs Eager Loading

The Trap of Lazy Loading vs Eager Loading

The problem usually starts with a well-intentioned abstraction. Most modern ORMs are designed to be “helpful” by defaulting to lazy loading. On the surface, it feels efficient; you only fetch the data you actually touch. But in a production environment, this convenience is a debt you’ll eventually have to pay with interest. When you iterate over a collection of objects and trigger a new fetch for every single child relationship, you aren’t just writing code—you are systematically dismantling your database performance.

The choice between lazy loading vs eager loading isn’t just a configuration toggle; it’s a fundamental decision about how your application breathes. I’ve spent many late nights staring at traces where a single API call triggered hundreds of tiny, individual requests. This is the essence of reducing database round trips. If you know you need the related data, you must tell the ORM to grab it all in one go. If you don’t, you’re essentially asking the database to play a game of ping-pong with your application server, one tiny, expensive packet at a time.

Identifying Inefficient Database Access Before It Breaks

Identifying Inefficient Database Access Before It Breaks

You won’t find this issue in a unit test. Your tests will pass, your local environment will feel snappy, and the CI/CD pipeline will remain green. The problem is that N+1 patterns are a latency debt that only comes due when your dataset grows or your traffic spikes. You’ll know you’re in trouble when your application’s response time starts creeping up linearly with every new record added to the database.

To catch this before it hits production, you have to stop looking at your application code and start looking at the wire. I rely heavily on SQL query profiling tools to see what is actually happening under the hood. If you see a rhythmic, repetitive pattern of nearly identical queries hitting your logs, you aren’t looking at a busy system; you’re looking at a failure in your ORM relationship fetching strategies.

Don’t wait for a high-severity alert to tell you that your database is struggling. Instead, make it a habit to audit your logs for excessive database round trips. If a single API request is triggering fifty separate calls to fetch related entities, you’ve already lost the battle for scalability.

How to actually catch them before they hit production

  • Stop looking at the application logs and start looking at the database’s process list. When an N+1 is happening, you won’t see a single massive query; you’ll see a rhythmic, pulsing wave of identical queries hitting the engine. If you see a pattern that looks like a heartbeat, you’ve found your leak.
  • Use a profiler that visualizes query counts per request. Most modern ORMs have a way to log the number of queries executed during a single lifecycle. If your “Get User Profile” endpoint is triggering 42 queries to fetch 42 different preference rows, your profiler should be screaming at you before you even look at the code.
  • Write a failing test that asserts the query count. I don’t trust a fix just because the data looks right on my screen. I write a test that explicitly counts the SQL statements executed; if the count is higher than what I expect for a single entity, the test fails. It’s the only way to prevent a regression when someone else comes along and adds another “convenient” lazy-loaded property.
  • Audit your serializers and DTO mappers. This is where the real damage happens. You’ll think you’re just transforming an object, but your JSON serializer is walking through a collection and triggering a database round-trip for every single nested property. The bug isn’t in your service layer; it’s hiding in your transformation logic.
  • Check your “hidden” joins in complex ORM queries. Sometimes you think you’ve eager-loaded everything, but a single conditional check in your code—something as simple as `if (user.hasSubscription)`—can trigger a fresh, unoptimized query that bypasses your entire pre-fetching strategy. Always verify that your “eager” load actually stayed eager through the entire execution path.

Lessons from the Trenches

Stop trusting your ORM blindly; just because the code looks clean doesn’t mean your database isn’t screaming under the hood.

If you see a sudden, inexplicable spike in latency after a minor feature deployment, check your logs for a repetitive pattern of identical queries immediately.

Always write your commit message with the failure case in mind—if you can’t describe how your “fix” might trigger a new N+1, you haven’t actually understood the problem yet.

## The Cost of Convenience

“The problem isn’t that the code works; the problem is that it works perfectly in your local environment with ten rows of test data, only to turn into a slow-motion car crash the moment it touches a production database with a million.”

Pernille Askeland

Beyond the Quick Fix

Optimizing database performance: Beyond the Quick Fix.

At the end of the day, fixing an N+1 issue isn’t just about slapping an `.includes()` or a `JOIN` onto a query and calling it a day. It’s about understanding the tension between the convenience of an ORM and the reality of the metal underneath. You have to watch your logs, recognize those repetitive, rhythmic patterns of wasted round-trips, and realize that what looks like clean code is often just a slow-motion train wreck waiting for a traffic spike to trigger it. Don’t just aim for the happy path where everything loads in 20ms; build for the moment the dataset grows and the abstraction starts to leak.

I spent years fixing these exact same patterns in legacy payment engines, and if there is one thing I’ve learned, it’s that the most dangerous bugs are the ones that don’t crash the system—they just starve it. A system that works perfectly in your local dev environment is a lie. True engineering happens when you stop trusting the magic of the framework and start respecting the cost of every single database trip. It’s tedious work, and it isn’t glamorous, but it is the difference between a system that survives the weekend and one that collapses under its own weight.

About Pernille Askeland

I write the part the tutorial skipped: what it looks like when it breaks.

Scroll to Top