N+1 Queries and the DOM — When Performance Is an Architecture Problem, Not a Tuning Problem
There's a moment that repeats itself in engineering orgs of every size. A page that felt instant in development crawls in production. Someone opens the profiler, finds a slow query or a janky scroll, and ships a fix. A few weeks later, a different page does the same thing. Then another. The team starts to talk about performance the way you'd talk about weather — something that happens to you, that you can never quite get ahead of.
We've watched this pattern across a lot of codebases, in a lot of stacks. And the uncomfortable conclusion, almost every time, is that the slow query and the janky scroll are not separate problems. They're the same problem wearing two different costumes. Both are what happens when work that should occur once gets multiplied across a collection. Both are usually invisible at the scale you test with and brutal at the scale your users hit. And critically, both are architectural decisions that got made implicitly — not tuning knobs you forgot to turn.
The clearest example on the backend has a name engineers say with a sigh: the N+1 query problem. Its front-end twin doesn't have a tidy name, but it's just as common and just as costly. Understanding them as one pattern changes how you build — and, increasingly, how you let your tooling build for you.
The backend version: one query becomes a hundred and one
The N+1 query problem is one of the most well-documented performance anti-patterns in software, and it shows up the moment you put an ORM between your code and your database. The mechanism is simple. You fetch a list of N records — say, 100 users. Then, as you loop over them to render each user's most recent post, your ORM quietly issues one additional query per user to load that relationship. One query for the list, plus N queries for the children: N+1. A hundred users becomes a hundred and one round trips to the database (dev.to).
What makes this insidious is that the code reads beautifully. for user in users: render(user.latest_post) is exactly what a junior and a principal would both write. The query explosion is hidden behind property access that looks free. Many ORMs load related data lazily by default, which is convenient until it becomes the silent database killer it's frequently called (Medium / Javarevisited).
It's also nearly invisible in development. With ten seed records, 11 queries finish before you can blink. With a hundred thousand rows and real network latency between app and database, the same loop becomes a multi-second page load and a pegged database. The bug didn't change. The scale exposed it.
The fixes are well established, and they all share one idea: do the work in bulk instead of per item.
Eager loading. Tell the ORM up front which relationships you'll need, so it fetches them with a JOIN or a small fixed number of queries rather than one per row. Your 101 queries collapse to one or two. The caveat: eagerly loading deeply nested relations can produce huge, slow queries, so it's a tool, not a reflex (Reintech).
- Batching with a loader. When eager loading isn't practical — common in GraphQL, where resolvers fire independently — a batching layer like DataLoader collects all the IDs requested during a single execution tick and resolves them in one query, then hands each caller its slice. New loader instances are created per request to avoid leaking cached data between users (oneuptime).
Notice what both fixes have in common. Neither one makes an individual query faster. They change when and how often the work happens. That's the tell that you're looking at an architecture problem, not a tuning problem.
The front-end version: the same shape in the DOM
Now watch the identical pattern move to the browser.
Your component renders a list of 100 items. For each item, in a loop, your code reads a layout property — element.offsetWidth, getBoundingClientRect(), scrollHeight — and then writes a style based on it. Feels innocent. It's the front-end N+1.
Here's why it hurts. The browser tries to be lazy about layout, the same way the ORM is lazy about queries. It batches your DOM writes and recomputes geometry once, when it actually needs to. But the instant your JavaScript reads a geometric property after you've changed the DOM, the browser is forced to stop and recalculate layout immediately so it can give you an accurate answer. That's a forced synchronous layout — also called a forced reflow (web.dev). Do it once and you pay once. Do it inside a loop — read, write, read, write — and you force the browser to recompute layout on every iteration. Multiple forced reflows in quick succession is layout thrashing (DebugBear).
Layout is synchronous and runs on the main thread; it computes the dimensions and position of elements in the document, and while it runs, nothing else can (web.dev). So a thrashing loop doesn't just make a scroll feel sticky — it blocks the thread that also has to respond when the user clicks. The structural fix is the same idea as eager loading, ported to the DOM: batch your reads, then do your writes. Read all the geometry you need first, while the browser can still hand you last frame's cached values, then perform every write in a second pass (web.dev). One layout pass instead of a hundred. One bulk operation instead of per-item churn. The N+1 fix, again.
React teams hit a closely related version. The framework's default is to re-render a component and its children when state or props change. Pass a freshly created function or object as a prop on every parent render — onClick={() => ...} inline — and every child sees a "new" prop and re-renders, even when nothing the user cares about actually changed. In a long list, one item's update can trigger the entire list to re-render. The mitigations — React.memo to skip unchanged children, useCallback and useMemo to keep references stable so memoization actually holds — are real, but they carry their own comparison cost and shouldn't be sprinkled everywhere by default (DebugBear, DigitalOcean). Same shape once more: work that should happen for one thing is multiplied across the collection, and the fix is to stop the multiplication, not to speed up the single unit.
And then there's the most literal front-end N+1 of all: a component that fires a network request per row — a fetch inside the .map() — instead of one request for the page. It is the backend N+1, re-implemented over HTTP, in the client.
Why this is an architecture problem, not a tuning problem
Tuning is what you do to a sound design that's running hot: add an index, bump a cache TTL, tighten a hot function. Tuning assumes the shape of the work is right and the constant factors are wrong.
N+1 in all its forms is not that. The shape of the work is wrong. You're doing per-item work that should be per-collection work. No index makes 101 round trips as fast as one. No amount of memoizing a single component fixes a render strategy that fans out across a list. You can tune your way to a slightly less painful version of the wrong architecture, but you can't tune your way out of it. That's the line between the two categories, and it's worth saying plainly to your team: if the fix changes how often the work happens, it was an architecture decision all along — and it got made by default.
This reframing matters because it changes where you intervene. Performance treated as tuning is reactive: you wait for the profiler to light up, then you respond. Performance treated as architecture is a decision you make at design time — how this list loads its data, how this view reads and writes the DOM, how this component tree decides what to re-render. Those decisions are cheap to make correctly up front and expensive to retrofit after the access pattern is baked into a dozen call sites.
It also reframes the cost for the people who don't read flame charts. A forced-reflow loop or a fan-out fetch degrades INP — Interaction to Next Paint, the responsiveness metric that became an official Core Web Vital on March 12, 2024, replacing First Input Delay. Google's "good" threshold is 200ms or less at the 75th percentile, and INP is currently the most commonly failed Core Web Vital (web.dev, corewebvitals.io). That's not an engineering abstraction. That's a slow, sticky page that costs you search ranking and conversions — a CEO-legible number that traces directly back to an architectural choice an engineer made, or didn't make, months earlier.
How to catch the pattern structurally — including with agents
Here's the part most performance advice skips. If you accept that N+1 is architectural, then the goal isn't to get better at finding it after the fact. It's to make it hard to ship in the first place. You catch architecture problems with structure, not with vigilance — because vigilance doesn't survive contact with a deadline.
A few structural defenses we lean on:
Make the invisible visible in the loop you already run. The N+1 query is invisible precisely because the query count isn't in front of you. So put it there. Assert on query counts in tests — a request that should issue 3 queries fails the test the day someone makes it issue 47. Most ORM ecosystems have a strict mode or a tool that raises when a lazy load fires mid-loop. On the front end, Chrome DevTools flags forced synchronous layouts directly: forced reflows show up as "Layout" blocks in the flame chart, with a warning marker pointing at the exact JavaScript line that triggered them (web.dev).
- Budget the metric, not the vibe. "Feels fast" is unfalsifiable. An INP budget enforced in CI is not. When the number that governs ranking and conversion is a gate in the pipeline, regressions get caught by a machine at 2 a.m. instead of by a customer.
- Write the test that encodes the pattern. A test that says "rendering 1,000 rows issues one network request, not one thousand" is a permanent, executable statement of the architectural decision. It's worth more than any code comment, because it fails loudly when someone violates it.
This is where agentic development changes the economics, and it's worth being concrete about why. An AI coding agent left to its own devices will happily write the N+1 — it produces exactly the clean, readable per-item loop that humans write, for the same reason humans do. Generation alone makes the anti-pattern easier to produce at volume, not harder.
The leverage isn't in the generation. It's in the loop around the generation — the harness. An agent operating inside a well-built harness has the same structural defenses a senior engineer would insist on: a test suite that asserts query counts, an INP budget that gates the build, a profiler it can run and read, observability it can query. So the agent writes the naive version, the harness runs the tests, the query-count assertion fails, the agent sees the failure, recognizes the N+1, and rewrites it as a batch — before a human ever reviews it. The anti-pattern gets caught structurally, on every change, at machine speed, rather than discovered in production three sprints later.
That's the real argument, and it's the through-line of everything above. Performance is an architectural property. Architectural properties are protected by structure — tests, budgets, observability — not by hoping the right person notices in review. A bare AI agent that only generates code inherits none of that protection. An agent inside a harness that runs your tests, reads your profiler, and respects your budgets inherits all of it. The difference between those two is the difference between shipping the N+1 faster and never shipping it at all.
Where to start
Pick your slowest page. Count the queries it issues and the network requests it fires — the raw counts, not the timings. Open a profiler and look for the tell-tale repeated "Layout" blocks. You're not looking for a slow unit of work. You're looking for a fast unit of work, multiplied by N. That multiplication is the architecture talking.
This is the kind of work Facet does with engineering teams: not chasing individual slow spots, but building the structural defenses — the tests, the budgets, the observability, and increasingly the agentic harness — that keep the whole class of N+1 problems from reaching production in the first place. If your team keeps fixing the same shape of performance bug on a different page every month, the problem isn't the page. It's that nothing in your pipeline knows the pattern is forbidden. We can help you teach it.
Facet Interactive is a boutique systems integrator and digital consultancy. We help technology-forward teams modernize their stacks and build the engineering structure — including agentic development harnesses — that makes good architecture the default, not the exception. If you’d like to learn more through a conversation with us, get in touch.

