ProductionReady
productionready
FROM PROTOTYPE TO PRODUCTION
← Blog

We stress-tested an AI-built app. It broke at 200 users.

By Herbert Samuels · Founder of ProductionReady.co. Thirty years in software development, architecture, cloud infrastructure, and IT management across marketing technology, advertising, healthcare, and financial services.

We load-tested a Cursor-built project management application that had about 200 active users and was adding new accounts every week. The builder wanted to know if the app could handle growth. The answer was: not yet.

At 200 concurrent connections (simulating the existing user base all active at once), average API response time went from 180ms to 14 seconds. At 400 concurrent connections, the database connection pool was exhausted and the server started returning 503 errors. At 600, it stopped responding entirely.

The application wasn't poorly built. The code was clean, well-organized, and did exactly what the builder asked for. The performance problems were in architectural decisions that AI tools make by default and that only surface under load.

The application

A project management tool built with Cursor. React frontend, Express backend, PostgreSQL database hosted on Railway. Users create projects, add tasks, assign team members, track time, and generate basic reports. About 200 active users, mostly small teams of 3 to 8 people.

The codebase was one of the cleaner Cursor-built projects we've reviewed. TypeScript throughout, consistent patterns, sensible component structure. The builder had clearly iterated on the product and responded to user feedback. It worked well in daily use.

What broke under load

Problem 1: N+1 queries on the dashboard

The dashboard was the most-visited page. It loaded the user's projects, then displayed each project's recent tasks with assignee names.

The implementation: one query to fetch projects, then for each project a separate query to fetch its tasks, then for each task a separate query to fetch the assignee's name. With a user who had 50 projects averaging 8 tasks each, a single dashboard load executed roughly 450 database queries.

At low concurrency this was invisible. Each query ran in 1 to 3 milliseconds, and the total page load was under 200ms. At 200 concurrent users, the database was processing 90,000 queries nearly simultaneously for dashboard loads alone. Query queue times exploded, and what had been a 180ms page became a 14-second wait.

The N+1 pattern is the single most common performance problem in AI-built applications. AI tools generate it because it's the most straightforward way to load related data: get the parent records, then loop through them and fetch children. The code is correct and readable. It's also the database equivalent of making 450 trips to the grocery store when you could make one.

Problem 2: No database indexes

The PostgreSQL database had no indexes beyond the automatic primary key indexes. Every query that filtered by project_id, assigned_to, status, or created_at performed a full table scan, reading every row in the table to find the matching ones.

With a few hundred rows per table, this was fast enough to be invisible. As the tables grew past a few thousand rows, each query got incrementally slower. Under concurrent load, the effect multiplied: hundreds of simultaneous full table scans competing for disk I/O.

AI tools almost never create indexes. Creating a table and querying it works without indexes, and the performance difference is invisible at development scale. The tool has no reason to add them because nothing fails without them. The failure is gradual: the app gets slower over weeks and months, and the builder assumes it's a hosting issue.

Problem 3: No caching

Every request hit the database directly. The list of team members (which changed maybe once a month) was fetched fresh on every page load. Project metadata was re-queried on every navigation. Even the current user's profile and permissions were loaded from the database on every single API request.

For data that changes infrequently, a caching layer (Redis, an in-memory cache, or even a simple TTL on responses) can eliminate most database reads. The application had none. The builder hadn't considered it because the app was fast enough in development, and AI tools don't add caching unless specifically asked.

Problem 4: Connection pool exhaustion

The Express server used the default PostgreSQL connection pool configuration: 10 connections. Under normal use this was sufficient. Under load, with hundreds of N+1 query chains competing for 10 database connections, the pool was exhausted almost immediately. Requests queued up waiting for a connection, and the queue grew faster than connections were freed.

This is the failure mode that turned a slow application into an unresponsive one. The N+1 queries and missing indexes made the app slow. The undersized connection pool made it fall over.

What we changed

The fixes took four days. None of them required infrastructure changes, additional hosting costs, or changes visible to users.

Day 1: Query optimization. Replaced the N+1 query patterns with JOINs and batch queries. The dashboard went from 450 queries to 3. The project detail page went from 30+ queries to 2. The reporting endpoint went from hundreds of individual queries to a single aggregation query. We used Prisma's include and select features, which were available the entire time but hadn't been used because the AI-generated code fetched related data in loops.

Day 2: Database indexes. Added indexes to the six most-queried columns: project_id on the tasks table, assigned_to on tasks, status on both projects and tasks, created_at on tasks, and user_id on the sessions table. Each index took one line in a migration file. The total migration was 12 lines of SQL.

Day 3: Caching layer. Added Redis (Railway offers a free tier) as a cache for reference data: team member lists, project metadata, user permissions. Set TTLs appropriate to how often each type of data changes: 5 minutes for project metadata, 1 hour for team lists, 30 seconds for permissions. Added cache invalidation on write operations so updates appear immediately.

Day 4: Connection pool and load verification. Increased the connection pool to 25 (appropriate for the Railway instance size), added connection timeout handling, and re-ran the load tests.

The results

After the four-day optimization:

MetricBeforeAfter
Dashboard queries per load~4503
Average response time (50 concurrent)180ms45ms
Average response time (200 concurrent)14,000ms95ms
Maximum concurrent users before degradation~150~2,000
Database CPU under load98%12%
Monthly hosting cost$29$29 (Redis free tier)

The application handles roughly 13x the concurrent load on identical infrastructure. The builder's $29/month Railway plan, which was about to become insufficient, now has years of headroom.

Why AI tools build this way

The query patterns, missing indexes, absent caching, and default connection pool aren't bugs. They're the simplest correct implementation of what was requested. "Load projects and show their tasks" produces a loop that queries each project's tasks individually, because that's the most readable way to express the relationship.

AI tools optimize for correctness and clarity at development scale. They don't simulate production load, because they can't. They don't add indexes, because nothing fails without them during development. They don't add caching, because the response is already fast with one user. Every optimization described above requires understanding how the application will behave under conditions that don't exist yet.

This is also where automated scanning tools hit their limit. An LLM-based code scanner will look at the N+1 pattern and see correct, clean code. The queries are properly parameterized. The error handling is present. There's nothing syntactically wrong. The performance problem is architectural: it's about how hundreds of correct queries interact under concurrent load, and that's not something you can evaluate by reading source files.

What to check in your own application

If your AI-built application has real users and you haven't load-tested it, here are the quick indicators:

Count your queries. Enable query logging in development, load a page, and count the queries. If a single page load produces more than 10 to 15 queries, you likely have N+1 patterns.

Check your indexes. Look at your database schema. If the only indexes are on primary keys, you're missing indexes. Any column that appears in a WHERE clause, a JOIN condition, or an ORDER BY should probably be indexed.

Watch your response times. If pages take noticeably longer than they did a month ago, and you haven't added new features, your data is growing past the point where unindexed queries perform well.

Check your connection pool. If you're using the default pool size (often 5 or 10), it's probably too small for production. A good starting point is 2 to 3 connections per CPU core on your database server.

The reassuring part: these fixes are usually measured in days, not weeks. The code doesn't need to be rewritten. The architecture needs targeted adjustments that the AI tool didn't know to make.

Free Production Readiness Check

See where your app stands in 5 minutes.

16 questions across 8 categories: authentication, secrets, data layer, API security, and more. Get a scored report with prioritized recommendations.

ProductionReady
productionready
FROM PROTOTYPE TO PRODUCTION
Security audits, hardening, and backend builds for vibe-coded applications.
Company