Nurture TechnologiesNurture Tech
Architecture15 min read·July 24, 2026

Your Application Was Fast at 100 Users. Why Is It Slow at 10,000?

PostgreSQLRedisNode.jsDatadogAWS CloudFront

Performance that was fine at small scale almost always degrades through the same patterns at growth: N+1 queries, missing indexes, no caching, synchronous blocking, and missing pagination. The good news is these are all diagnosable and fixable without a rewrite.

Problem Summary

An application that performs well at 100 users and poorly at 10,000 is not a badly designed application it is an application that has not yet been optimised for production load. Almost every early-stage engineering team deliberately defers performance work in favour of feature development, which is the correct trade-off. The problem emerges when the performance debt accumulates to the point where it affects user experience and retention. The patterns of degradation are highly predictable: N+1 query patterns that generate hundreds of database calls per request, missing indexes that cause sequential table scans, no caching layer meaning every request hits the database, connection pool exhaustion under concurrent load, synchronous operations blocking the event loop, and assets served from the application server rather than a CDN.

Symptoms

  • API response times above 2 seconds during peak hours but acceptable during off-peak
  • Database CPU consistently above 70% despite reasonable query volume
  • Connection pool exhausted errors appearing in application logs
  • List endpoints (dashboards, feeds, tables) are disproportionately slower than detail endpoints
  • Performance degrades linearly with data volume queries that return the same number of rows take longer as the table grows
  • Asset load times (images, JS bundles, CSS) are slow globally but fast in the deployment region
  • Event loop lag metrics in Node.js or thread pool exhaustion in Java/Python indicate synchronous blocking
  • Cache hit rate below 20% despite having a Redis instance deployed

Business Impact

Every 100ms of additional latency reduces conversion rates in e-commerce and SaaS signup flows by measurable percentages. For an existing product, slow performance directly correlates with churn: users who experience repeated slowness cancel at 2-3x the rate of users in fast cohorts. Beyond user impact, performance problems under load indicate architectural fragility that will cause outages as growth continues degradation today means downtime at the next traffic spike.

Root Cause

Performance degradation at scale is almost never a single root cause. It is a combination of query patterns that were acceptable at small data volumes becoming unacceptable as tables grow, caching that was deferred and never implemented, synchronous blocking operations that were invisible when the event loop was lightly loaded, and infrastructure that was sized for launch traffic rather than growth. The fix requires identifying the most impactful bottleneck first and resolving in order of impact.

Do not optimise based on assumptions. Profile first. The slowest endpoint in production is almost never the one developers suspect. Use APM tooling (Datadog, New Relic, or query-level logging) to identify the actual slow paths before writing any code.

Diagnosis

Step 1: Find Your Slowest Endpoints and Queries

Enable query logging in your database with a slow query threshold (100ms is a good starting point). Review the slow query log after one hour of production traffic. The same query appearing hundreds of times per minute is an N+1 pattern. A query that appears once but takes 5 seconds is a missing index or a table scan problem.

slow_query_log.sql
-- Enable slow query logging in PostgreSQL (add to postgresql.conf)
-- log_min_duration_statement = 100  -- log queries over 100ms

-- Find the slowest queries over the last hour using pg_stat_statements
SELECT
  LEFT(query, 100) AS query_snippet,
  calls,
  ROUND(total_exec_time::numeric / 1000, 2) AS total_seconds,
  ROUND(mean_exec_time::numeric, 2) AS avg_ms,
  ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
  rows
FROM pg_stat_statements
WHERE calls > 10
ORDER BY mean_exec_time DESC
LIMIT 20;

Step 2: Identify N+1 Query Patterns

An N+1 query pattern occurs when code loads a list of N items and then makes one additional database query per item to load a related resource. The result is N+1 database calls for a single page load. At 100 users it manifests as 20-30 queries per request. At 10,000 users with 20 concurrent requests, it generates 400-600 simultaneous database queries, exhausting the connection pool.

n_plus_one_example.js
// BAD: N+1 pattern - 1 query for posts + 1 query per post for author
const posts = await db.query('SELECT * FROM posts LIMIT 20');
for (const post of posts) {
  post.author = await db.query(
    'SELECT * FROM users WHERE id = ${post.author_id}',  // N additional queries
    [post.author_id]
  );
}

// GOOD: Single query with JOIN or two queries with IN clause
const posts = await db.query(`
  SELECT p.*, u.name AS author_name, u.avatar_url AS author_avatar
  FROM posts p
  JOIN users u ON u.id = p.author_id
  ORDER BY p.created_at DESC
  LIMIT 20
`);

Step 3: Check for Missing Database Indexes

Use PostgreSQL's query planner to identify sequential scans on large tables. Any sequential scan (Seq Scan in EXPLAIN output) on a table with more than 10,000 rows that is called frequently is a strong candidate for an index. Pay particular attention to foreign key columns, columns used in WHERE clauses, and columns used in ORDER BY clauses on list endpoints.

missing_indexes.sql
-- Find tables with high sequential scan counts
SELECT
  schemaname,
  tablename,
  seq_scan,
  seq_tup_read,
  idx_scan,
  n_live_tup AS row_count,
  ROUND(seq_scan::numeric / NULLIF(seq_scan + idx_scan, 0) * 100, 1) AS seq_scan_pct
FROM pg_stat_user_tables
WHERE seq_scan > 100
  AND n_live_tup > 10000
ORDER BY seq_tup_read DESC
LIMIT 20;

-- EXPLAIN ANALYZE a specific slow query
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10;

Step 4: Audit the Caching Layer

If you have Redis deployed but cache hit rates are low, the caching strategy is incorrect. Common problems: cache keys are too specific (caching per-user data that should be cached per-resource), TTLs are too short causing constant cache misses, or the code bypasses cache for writes and never invalidates, leading developers to distrust the cache and query the database directly.

redis_cache_stats.sh
# Check Redis hit/miss ratio
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"

# Calculate hit rate manually
# hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses) * 100

# Check memory usage and key count
redis-cli info memory | grep -E "used_memory_human|used_memory_peak_human"
redis-cli dbsize

# Find keys with no TTL (potential memory leak)
redis-cli --scan --pattern "*" | while read key; do
  ttl=$(redis-cli ttl "$key")
  if [ "$ttl" == "-1" ]; then
    echo "No TTL: $key"
  fi
done | head -50

Step 5: Check for Missing Pagination on List Endpoints

A list endpoint that returns all records without pagination is fine at 100 records and catastrophic at 100,000. At 10,000 users, a 'get all my items' endpoint may return 10,000 rows, serialise them to JSON, and send a 10MB response on every page load. Cursor-based pagination is the correct implementation for feeds and tables; offset pagination is acceptable for small, bounded datasets.

cursor_pagination.js
// Cursor-based pagination for feed endpoints
async function getOrders(userId, cursor, limit = 20) {
  const query = cursor
    ? `SELECT * FROM orders
       WHERE user_id = \${userId}
       AND id < \${cursor}
       ORDER BY id DESC
       LIMIT \${limit + 1}`
    : `SELECT * FROM orders
       WHERE user_id = \${userId}
       ORDER BY id DESC
       LIMIT \${limit + 1}`;

  const rows = await db.query(query, cursor ? [userId, cursor] : [userId]);
  const hasMore = rows.length > limit;
  const items = rows.slice(0, limit);

  return {
    items,
    nextCursor: hasMore ? items[items.length - 1].id : null,
    hasMore
  };
}

Production Failure Scenarios

Scenario 1: Dashboard Query Kills Database at Peak Hours

A dashboard query joins 5 tables and aggregates data without any caching. At 100 users, it runs in 200ms. At 10,000 users with 50 concurrent dashboard loads, 50 instances of a 200ms complex join run simultaneously, consuming all database connections and pushing CPU to 100%. All other queries queue behind them. The fix: cache the aggregated result for 60 seconds, add a materialized view for the aggregation, and implement a separate read replica for reporting queries.

Scenario 2: File Upload Processing Blocks the Event Loop

Image resize processing runs synchronously in the Node.js request handler. At low traffic, it is imperceptible. At high traffic, image upload requests block the event loop for 200-500ms, causing all other requests to queue. Fix: offload file processing to a background job queue (BullMQ, SQS), respond immediately with a job ID, and notify the client via webhook or polling when processing is complete.

Scenario 3: Assets Served from App Server Instead of CDN

JavaScript bundles, CSS, and images are served by the application server rather than a CDN. Users in regions geographically far from the deployment region experience 2-3 second asset load times. The application server wastes compute serving static files. Fix: configure an S3 bucket with CloudFront distribution for all static assets, update webpack or Vite to output asset URLs to the CDN domain, and set aggressive Cache-Control headers on static assets.

Scenario 4: Connection Pool Exhausted Under Load

The application database connection pool is set to 10 connections (a common default). Under load, 15 concurrent requests each wait for a database connection, causing timeout errors and cascading failures. Fix: set the connection pool size to (number of CPU cores * 2) + effective spindle count, implement connection pool monitoring with alerts at 80% utilisation, and use PgBouncer for connection multiplexing if running many application instances.

Prevention Checklist

  • Enable pg_stat_statements in PostgreSQL and review slow queries weekly
  • Add EXPLAIN ANALYZE to your code review checklist for any new database query
  • Use an ORM or query builder that warns about N+1 patterns (Prisma, TypeORM strict mode)
  • Add indexes on all foreign key columns and all columns used in WHERE clauses on high-frequency queries
  • Implement Redis caching for all aggregation queries and list endpoints that are called more than 10 times per second
  • Set pagination as a required parameter on all list endpoints never return unbounded result sets
  • Deploy static assets to a CDN from day one, even before you have performance problems
  • Size connection pool correctly: default values are almost always wrong for production
  • Add APM monitoring (Datadog, New Relic) with p95 and p99 latency alerts on all API endpoints
  • Set up a read replica for reporting queries and background jobs to isolate them from transactional workload

Resolution Summary

Start with APM data to find the slowest endpoints, then profile the queries behind those endpoints with EXPLAIN ANALYZE. Fix N+1 patterns first (highest impact per engineering hour), then add missing indexes, then implement caching on the top 5 most-called endpoints, then add pagination to unbounded list endpoints, then move assets to CDN. This sequence, executed in order, typically reduces p95 response time by 70-80% without any infrastructure changes.

Lessons Learned

  • N+1 queries are the single highest-impact performance problem in most web applications at early scale
  • EXPLAIN ANALYZE is the most valuable tool in your performance debugging arsenal run it on every slow query before writing any code
  • Caching without a cache invalidation strategy creates correctness bugs design both together
  • Connection pool defaults are almost always wrong; size them explicitly based on your deployment architecture
  • Static assets on CDN is not a premature optimisation it is infrastructure hygiene that should be implemented at launch
  • Performance problems at 10,000 users were almost always present at 1,000 users but below the visibility threshold

Conclusion

Application performance at scale degrades through predictable, well-understood patterns. The good news is that these patterns are fixable without architectural rewrites in the majority of cases. Profile before optimising, fix the highest-impact issues first, and treat performance work as a regular part of the engineering sprint rather than a crisis response. An application that performs well at 10,000 users is not fundamentally different from one that performs well at 100 it has just gone through the systematic process of identifying and fixing the patterns that do not scale.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

What is an N+1 query problem?+

An N+1 query problem occurs when code executes one query to retrieve a list of N items and then executes one additional query per item to retrieve related data. For a list of 50 posts, this produces 51 queries. At scale, this pattern generates thousands of database queries per page load. The fix is to use JOINs or an IN clause to retrieve all related data in one or two queries.

How do I know if I need a database index?+

Run EXPLAIN ANALYZE on your slow queries. If the output shows 'Seq Scan' on a table with more than 10,000 rows, the query is reading the entire table. An index on the WHERE clause column or JOIN column will change this to an 'Index Scan', which is typically 10-100x faster. Always add indexes on foreign key columns and frequently-queried filter columns.

What should my database connection pool size be?+

A common formula is: (number of CPU cores * 2) + number of effective spindles (disk I/O threads). For a 4-core database with SSD storage, a pool of 9-10 connections per application instance is a starting point. If you run 5 application instances, your database receives up to 50 connections total ensure your max_connections PostgreSQL setting accommodates this.

When should I use Redis caching vs. in-memory caching?+

Use Redis when: you run multiple application instances (in-memory caches are per-instance and cause inconsistency), the cached data needs to survive application restarts, or the cached objects are large enough that duplicating them in memory across instances is wasteful. Use in-memory caching (LRU cache in the application process) for small, frequently-accessed lookups like configuration values or feature flags.

What is the difference between offset pagination and cursor pagination?+

Offset pagination uses LIMIT and OFFSET SQL clauses (e.g. page 5 of 20 results = OFFSET 80 LIMIT 20). It becomes slow on large tables because the database must count and skip over OFFSET rows. Cursor pagination uses the last record's ID or timestamp as a bookmark for the next page. It is O(1) regardless of how deep into the result set you are. Use cursor pagination for feeds and timelines; offset pagination is acceptable for small, bounded datasets.

How much does a CDN improve performance?+

For users geographically close to your origin server, a CDN improves asset load time by 20-40% through connection reuse and caching. For users 3,000+ miles from your origin, a CDN improves asset load time by 60-80% by serving from an edge location that is physically closer. The impact on TTFB for the HTML document (which must still come from your origin for dynamic content) is smaller but the total page load time improvement is significant.

Should I use a read replica for all read queries?+

No. Route only read-heavy, non-time-critical queries to a read replica: reports, analytics dashboards, background export jobs, and search queries. Keep transactional reads (checking inventory before a purchase, reading a record immediately after writing it) on the primary to avoid replication lag issues. The wrong read/write split can cause correctness bugs that are harder to debug than the original performance problem.

What APM tool should I use for performance monitoring?+

Datadog APM and New Relic are the most comprehensive options for full-stack visibility including query-level tracing. For smaller teams, Sentry Performance is more cost-effective and integrates with error tracking. For database-specific monitoring, pganalyze (PostgreSQL) provides query-level insights that general APM tools miss. Start with one tool and expand based on where your visibility gaps are.

Is vertical scaling (bigger server) a valid fix for performance problems?+

Vertical scaling is a valid short-term fix when you have identified a genuine resource constraint (CPU or memory bound) and need time to implement the correct code-level fix. It is not a permanent solution because it has a ceiling, is expensive at large scale, and does not fix the underlying code problems that will resurface when you outgrow the larger server. Use vertical scaling to buy time, then fix the root cause.

How do I test performance before it becomes a production problem?+

Load test your API endpoints with a tool like k6 or Artillery before major releases. Set up a staging environment that mirrors production data volume (anonymised), and run load tests that simulate 5x your current peak traffic. This will surface N+1 queries, connection pool exhaustion, and missing indexes in staging rather than in production at 2am.