Performance Traps in AI-Generated Code: N+1 Queries, Missing Indexes, and Re-Renders
AI-generated code is often fluent. It reads well, follows naming conventions, and passes a quick manual scan without raising flags. What it does less reliably is perform. After auditing dozens of vibe-coded backends and frontends, the same slow patterns keep appearing - not because AI models are careless, but because they optimize for correctness in isolation, not efficiency at scale.
This post covers the three performance traps I find most often - N+1 queries, missing database indexes, and unnecessary React re-renders - with concrete examples and fixes you can apply today.
Why AI Code Tends to Be Slow
AI models learn from example code. Most example code is written for clarity, not throughput. A tutorial showing how to fetch a list of orders and their customers will rarely optimize for the 50,000-order case - it just needs to work for the reader.
When you prompt an AI to build a feature, it mirrors that tutorial-level thinking. The result looks right. It returns the correct data. But under real load, the database query count explodes, an unindexed column scans every row, or the React component re-renders on every keystroke.
The gap between "works" and "works under load" is where most AI-generated code performance problems live.
Trap 1: N+1 Queries
The N+1 query problem is one of the oldest performance issues in web development, and AI-generated code produces it constantly. The pattern is simple: you fetch a list of N records, then for each record you run one more query to fetch related data. Result: N+1 database round-trips instead of one or two.
What it looks like in a PHP/Symfony codebase:
// What the AI generated
$orders = $orderRepository->findAll();
foreach ($orders as $order) {
echo $order->getCustomer()->getName(); // SELECT for each order
}
If there are 500 orders, this runs 501 queries. The page works fine in development with 10 test records. It crawls in production with real data.
The fix - eager load the relationship:
// In the repository
public function findAllWithCustomers(): array
{
return $this->createQueryBuilder('o')
->leftJoin('o.customer', 'c')
->addSelect('c')
->getQuery()
->getResult();
}
Now you load all orders and their customers in two queries - or one with a JOIN, depending on configuration. The difference is often an order of magnitude in response time.
In an API context, the same trap appears in serialization. Symfony's API Platform or a custom REST layer will eagerly serialize nested relations that the AI included "for completeness," triggering dozens of lazy-loaded queries per response. The fix is the same: use explicit JOIN fetches, or configure serialization groups to exclude relations you do not actually need in that endpoint.
How to detect it: Install the Symfony Profiler (or Laravel Debugbar if you are on Laravel) and load a list page. Look at the query count. If you are seeing more than 5-10 queries for a standard list page, you almost certainly have an N+1 somewhere. In production, slow query logs or a tool like Blackfire will surface the same problem.
Trap 2: Missing Database Indexes
AI-generated code sets up correct schema migrations but rarely adds indexes beyond the primary key. The model knows the columns need to exist; it does not reason about which columns will appear in WHERE clauses at scale.
What a missing index costs you:
A table with 1 million rows and no index on a frequently queried column performs a full table scan on every query. At 1,000 rows, this is invisible. At 100,000 rows, pages start feeling slow. At 1 million rows, requests time out.
Common missing index patterns I find in AI-generated migrations:
// AI-generated migration - correct but incomplete
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
// What it should have:
// $table->index('status');
// $table->index(['user_id', 'status']);
// $table->index('expires_at');
The status column will appear in WHERE clauses constantly ("give me all active subscriptions"). The expires_at column will be used in expiry jobs ("find subscriptions expiring in the next 7 days"). The user_id + status composite index serves the most common access pattern: "is this user's subscription active?"
None of these appear in AI-generated schema by default.
How to audit for missing indexes:
In PostgreSQL, run this query to find sequential scans on large tables:
SELECT schemaname, tablename, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_user_tables
WHERE seq_scan > 100
ORDER BY seq_tup_read DESC;
Tables with high seq_tup_read and low idx_scan are scanning too much data. Then check which queries hit those tables and what columns appear in their WHERE clauses.
In MySQL, EXPLAIN on your slow queries will show you type: ALL (full table scan) vs type: ref or type: range (index used). Every type: ALL on a table with more than a few thousand rows needs attention.
Adding indexes after the fact is safe - it does not break existing queries, it only makes them faster. The one cost is migration time on large tables, which you can manage with tools like pt-online-schema-change for MySQL or standard CREATE INDEX CONCURRENTLY in PostgreSQL.
For Symfony projects, I cover additional schema optimization patterns in more detail in the code quality consulting service.
Trap 3: Unnecessary React Re-Renders
On the frontend, AI-generated React code suffers from a different flavor of the same problem: it creates more work than necessary. The component renders correctly - but it renders too often, or it recreates expensive objects and functions on every render cycle.
Pattern 1: Object and array literals in JSX props
// What the AI generated
function UserList({ users }) {
return (
<DataTable
columns={[
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
]}
style={{ margin: '0 auto' }}
data={users}
/>
);
}
Every render of UserList creates a new columns array and a new style object. If DataTable uses React.memo or shouldComponentUpdate, those checks will always fail because the references are new even though the values are identical. The component re-renders on every parent render.
The fix:
const COLUMNS = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
];
const TABLE_STYLE = { margin: '0 auto' };
function UserList({ users }) {
return <DataTable columns={COLUMNS} style={TABLE_STYLE} data={users} />;
}
Move stable values outside the component. Use useMemo for values that depend on props or state but should not change on every render.
Pattern 2: Inline event handlers
// AI-generated - creates a new function on every render
<button onClick={() => handleDelete(item.id)}>Delete</button>
This is less problematic than it looks for simple cases, but in lists with hundreds of items rendered simultaneously, creating that many function instances per render adds up. Use useCallback when passing handlers to memoized child components, or restructure the event delegation to pass the item ID via a data attribute.
Pattern 3: Missing dependency arrays on useEffect
// AI-generated - runs on every render
useEffect(() => {
fetchUserData(userId);
});
// What it should be
useEffect(() => {
fetchUserData(userId);
}, [userId]);
AI models sometimes omit the dependency array entirely, causing the effect to run after every render. In a component that renders frequently (form inputs, filtered lists), this floods your API with requests.
How to detect unnecessary re-renders:
React DevTools Profiler is the right tool. Record an interaction, then look for components that render more times than you expect. The "why did this render?" view will show you which prop or state change triggered it. For a quicker scan during development, the why-did-you-render library attaches to your components and logs unnecessary re-renders directly to the console.
Putting It Together: The Performance Audit Order
When I review a new vibe-coded codebase for performance issues, I follow this order:
-
Database first. Enable slow query logs and use the profiler to look at query counts and patterns on key pages. N+1 problems and missing indexes compound each other - fix these before touching the frontend, because backend latency masks frontend issues.
-
API response size. Check how much data your endpoints return. AI-generated serializers often include every related object by default. Trim serialization groups to return only what the client actually renders.
-
Frontend re-renders. With a faster backend, profile React render counts. Look for the three patterns above. Stabilize props and callbacks before reaching for memoization -
React.memoon a component that receives new object references every render does nothing. -
Caching. Only after fixing the root causes does caching make sense. Caching a slow N+1 query hides the problem rather than solving it.
When to Get Help
If you have launched a vibe-coded product and are now seeing performance complaints from real users, these three areas are where I would start. They are fixable without rewriting the application - but they do require reading query execution plans, understanding object identity in React, and knowing which indexes actually help vs which add write overhead without payoff.
That judgment comes from doing this repeatedly. If you want a second set of eyes on a codebase that is starting to slow down under real traffic, reach out at hello@wolf-tech.io or visit wolf-tech.io. A performance audit typically takes one to three days and produces a prioritized list of changes with expected impact - so you are not guessing which fix matters most.
The goal is always the same: code that was written fast, made to run fast.

