Mature SaaS codebases still ship N+1 query patterns with uncomfortable regularity. The teams writing that code are not junior. The ORM is not new. The code has been reviewed. The feature passed load tests that looked fine at the time. Then a tenant with denser data hits a page that used to be fine, and the database starts answering hundreds of nearly identical queries for a request that should have made three.
N+1 is not mysterious. You load a parent collection, then the ORM lazily loads a child relation for each parent. Ten invoices become ten extra queries for line items. A hundred workspace members become a hundred permission lookups. The shape is always the same: one query for the list, then N queries for something related to each row. What changes over time is where the pattern hides, and how confident the team is that they already solved it years ago.
Where it hides after the obvious fixes
Most teams catch the classic controller loop early. Someone notices a list endpoint making one query per row, adds an include or a join fetch, and the ticket closes. The pattern returns later in less obvious places.
Serialization is a common second home. The service method returns an aggregate that looks complete. The serializer or DTO mapper then touches a navigation property that was never eagerly loaded. In development, the dataset is small and the lazy loads are cheap enough that nobody notices. In production, a dashboard that renders forty cards starts issuing forty separate queries during JSON construction. The application code looks clean because the query loop is no longer visible in the service method. It moved into property access.
Authorization and policy checks are another frequent site. A request loads a set of resources, then a policy evaluator asks whether the current user can see each one. If that check resolves membership, role, or ownership with a separate query per resource, the request cost scales with the number of objects on the page rather than with the complexity of the page. Teams often miss this because the authorization layer feels like infrastructure, not feature code.
Background jobs and fan-out workers deserve the same suspicion. A nightly reconciliation job that processes accounts one by one can hide an N+1 that never shows up in web request latency dashboards. The job still burns database capacity. During the window when it overlaps with peak traffic, the web tier starts looking mysteriously slow even though the hot path itself did not change.
Partial fixes create their own class of recurrence. A developer eagerly loads Order.Items, then a later change adds Order.Customer.BillingProfile access in a formatter. The original include still exists, so the code still looks intentional, but the new navigation path reintroduces the same shape. Code review rarely catches this because both the include and the property access look reasonable in isolation.
What it looks like in measurements
N+1 rarely announces itself as one catastrophic query. It announces itself as a request that issues too many queries, each of which looks individually respectable.
A useful first signal is query count per request, not average query duration. If a workspace settings page normally runs eight queries and occasionally runs two hundred and eight, you are probably looking at a list-driven lazy load. Duration histograms help next. You often see a cluster of short queries with nearly identical SQL text and different bind parameters: same plan, same table, same join shape, different primary keys. That is the fingerprint.
In Postgres, pg_stat_statements will show elevated calls for a statement whose mean time is not alarming. In application APM, the waterfall looks like a comb: one parent span followed by a long sequence of sibling database spans. In logs with request correlation ids, you see the same SELECT repeating inside a single request id. None of those tools require guessing. They show the structure directly.
Concrete numbers matter because they calibrate urgency. A page that issues 120 queries at 2 ms each is already spending roughly 240 ms in database round trips before application work, serialization, and network. If those queries average 8 ms under load because of pool contention or cache misses, the same page is suddenly near a second of database time. The SQL is not "slow" in the classic sense. The request is slow because it asked the database the same kind of question too many times.
ORM logging in development can catch this early if the team actually looks at it. Most teams turn query logging on for a week after an incident, then turn it off again because the noise is annoying. That is understandable. It is also why the next N+1 lands in production.
Why mature codebases keep producing it
The first reason is composition. Modern SaaS code rarely builds a page response in one place. Controllers call services. Services call domain helpers. Helpers call authorization. Serializers call presenters. Presenters touch model graphs. Each layer can introduce a relation access that looked free at the call site. The ORM makes that access look like a field read. It is not a field read. It is a potential query boundary.
The second reason is fixture poverty. Local and CI datasets are often too small to make N+1 expensive. Twenty rows hide a lot of sins. Production tenants with thousands of projects, documents, or memberships do not. The code path is identical. The cost function is not.
The third reason is feature accretion. An endpoint starts as a simple list. Later it grows badges, last-activity timestamps, nested owners, entitlement flags, and per-row action availability. Each addition can be correct in product terms and still multiply query count. Performance review at the original design time does not protect the endpoint after eighteen months of product work.
The fourth reason is premature confidence in "we use includes." Eager loading is not a culture. It is a specific claim about a specific access path. If the access path changes and the include list does not, the guarantee evaporates. Teams that treat eager loading as a completed migration rather than an ongoing contract tend to rediscover N+1 every few quarters.
Fixes that hold up
The durable fix is to make the data shape explicit at the boundary that needs it. If a page needs orders with line items and customer names, write a query that returns exactly that shape, or configure the ORM load with the exact graph required by that use case. Do not hope that ambient lazy loading will assemble it politely.
For list endpoints, prefer a small number of set-based queries over per-row resolution. Load the parents. Load the children with a WHERE parent_id IN (...). Join in application memory by key. This is boring and extremely effective. It also survives serializer changes better than deep include trees that try to anticipate every navigation.
When relation graphs get wide, split the response. A list view rarely needs every nested object required by a detail view. Returning a compact list DTO with only the fields the table renders keeps the query plan narrow and removes accidental property access. Detail endpoints can afford a richer graph because they operate on one entity, not hundreds.
Put a budget on query count for critical pages and enforce it in tests where practical. A test that seeds 50 parents and asserts the request stays under a fixed query ceiling will fail when someone adds a lazy access later. This is not glamorous, but it is one of the few checks that keeps mature codebases honest.
Finally, treat repeated short queries as a performance bug even when no single query looks bad. Database time is still time. Connection pool slots are still finite. A SaaS rendering path that multiplies cheap queries will eventually create an expensive page, usually for the customer with the most data, which is usually also the customer you least want to disappoint.
N+1 persists because it is locally reasonable. Each line of code asks for one more piece of information. The ORM obliges. The database answers quickly. The page still fails the only budget that matters: total work per request.