Your API Is Slow. Your Database Probably Isn't the Problem.

    Yash AminBy Yash Amin
    Last Updated: May 19, 2026•10 min read
    Debugging slow API performance with database query analysis and N+1 detection

    When an API endpoint is slow, the database gets blamed immediately. Engineers add read replicas, upgrade the instance class, or throw caching in front of the problem, none of which helps if the database was never the bottleneck.

    In our experience across hundreds of performance engagements, 80% of slow API endpoints trace to the application layer: N+1 query patterns from ORM misconfiguration, connection pool exhaustion preventing queries from starting, full result sets being transferred when three columns would do, or multiple serial database calls that could be issued in parallel. The database is often running its individual queries in 2-5ms.

    The API response time is 800ms. The gap is entirely in the application.

    This article gives you the diagnostic framework to find where the time actually goes, the four most common causes of application-layer database slowness, and the fixes for each.

    Prefer to skip the profiling and have an expert find it? Book a 30-min diagnostic ?

    Step 1: Measure Before You Guess, Then Isolate Where the Time Is

    Before touching anything, instrument the request to find where the time actually goes. The goal is to separate total request time into: time spent waiting for a database connection, time spent executing database queries, time spent in application code between queries, and time spent serialising and sending the response. Most APM tools (Datadog APM, New Relic, Sentry Performance, OpenTelemetry) show distributed traces with database spans.

    If you do not have APM, add timing instrumentation manually at the database call boundaries � log the time before and after each query execution, not just the total request duration. The pattern you are looking for: if individual query execution times are under 10ms but total request time is 800ms, the problem is not query performance. It is either the number of queries (N+1), the time waiting for a connection, or application processing between queries.

    If one query execution time is 800ms, the problem is in that query: use EXPLAIN ANALYZE and the slow query log. OpenTelemetry is worth adopting specifically for this: its JavaScript and Python API packages each passed a billion downloads in the past year, and it is now the default tracing layer most APM vendors instrument against. Teams that put OpenTelemetry-based tracing in front of their slow-endpoint investigations report cutting mean time to resolution by roughly half, because a trace with per-span timings turns a guessing exercise into a five-minute read of which span is actually wide.

    Most teams still only collect metrics and logs and skip traces entirely, which is exactly the data you need to tell an N+1 pattern apart from a genuinely slow query without adding a single index.

    -- pg_stat_statements: find which queries your slow endpoint is running
    -- Run this before and after a slow request to see what fired
    SELECT
      calls,
      round(mean_exec_time::numeric, 2) AS avg_ms,
      round(total_exec_time::numeric, 2) AS total_ms,
      substring(query, 1, 100) AS query_short
    FROM pg_stat_statements
    WHERE query NOT LIKE '%pg_stat%'
    ORDER BY calls DESC
    LIMIT 20;
    
    -- If you see the same query hundreds of times in one minute: N+1 pattern
    -- If one query shows avg_ms > 100: fix the query itself
    -- If all queries are fast but count is high: N+1 or unnecessary fetching

    Related: database performance optimization, pg_stat_io in PostgreSQL 18 for pinpointing I/O bottlenecks

    Cause 1: N+1 Queries, the Silent Request Killer

    The N+1 query pattern occurs when code fetches a list of N records and then issues one additional query per record to load related data. 5ms. 5ms per query = 100ms in database time + 100ms in network round-trip time + 100ms in connection overhead = 300ms from database operations alone.

    Add application serialisation and the response reaches 600ms. customer triggers a SELECT query. In Django, this is an un-prefetched ForeignKey.

    In Rails, an association without eager loading. In Prisma, a related model access without include. In all cases, the fix is the same: batch the relationship load using a single JOIN or a WHERE IN query.

    ).

    -- Django: bad (N+1)
    orders = Order.objects.filter(status='pending')
    for order in orders:
        print(order.customer.name)  # triggers 1 query per order
    
    -- Django: good (prefetch_related eliminates N+1)
    orders = Order.objects.filter(status='pending').select_related('customer')
    
    -- Prisma: bad (N+1)
    const orders = await prisma.order.findMany({ where: { status: 'pending' } });
    for (const order of orders) {
      const customer = await prisma.customer.findUnique({ where: { id: order.customerId } });
    }
    
    -- Prisma: good (include eliminates N+1)
    const orders = await prisma.order.findMany({
      where: { status: 'pending' },
      include: { customer: true }
    });
    
    -- SQL: always prefer a single JOIN over N individual lookups
    SELECT o.id, o.total, c.name
    FROM orders o
    JOIN customers c ON c.id = o.customer_id
    WHERE o.status = 'pending';

    Related: our deep dive on the N+1 query problem, how to debug slow SQL queries

    Cause 2: Connection Pool Exhaustion, and Why Queries Appear Slow Without Being Slow

    Connection pool exhaustion looks exactly like slow query execution in most monitoring tools. The application waits 300ms before a query runs. The query itself takes 5ms.

    The total is 305ms, and 300ms of that is the application waiting for a free connection from the pool, not database execution. This pattern appears in APM traces as a long database span with a very short execution segment inside it. In pg_stat_activity, the connection enters the 'idle' state and then 'active' state after a delay: the delay being the wait for a pool slot.

    Connection pool exhaustion happens when all pool connections are busy and new requests queue behind them.

    • A slow query: holding a connection for hundreds of milliseconds

    • An uncommitted transaction: a transaction that is not committed promptly

    • A scale-up event: a Kubernetes deployment scale-up that outpaces the pool capacity

    The fix is either increasing the pool size (if the database can handle more concurrent backends), adding PgBouncer to multiplex connections, or finding and fixing the slow query or uncommitted transaction that is holding connections too long.

    -- Detect pool exhaustion: look for requests queuing in your pool
    -- Node.js pg pool example: add event listeners
    const pool = new Pool({ max: 20 });
    pool.on('acquire', () => { /* connection acquired from pool */ });
    pool.on('connect', () => { /* new connection created � pool was empty */ });
    
    // Log pool stats to detect exhaustion
    setInterval(() => {
      console.log({
        total: pool.totalCount,
        idle: pool.idleCount,
        waiting: pool.waitingCount  // non-zero = pool exhaustion occurring
      });
    }, 5000);
    
    -- PostgreSQL: find long-running transactions holding connections
    SELECT
      pid,
      now() - xact_start AS transaction_age,
      state,
      application_name,
      substring(query, 1, 80) AS current_query
    FROM pg_stat_activity
    WHERE xact_start IS NOT NULL
      AND now() - xact_start > INTERVAL '30 seconds'
    ORDER BY transaction_age DESC;

    Related: fixing PostgreSQL connection exhaustion with PgBouncer

    Cause 3: Fetching More Data Than the Endpoint Needs

    SELECT * is the most common form of over-fetching, but it is not the only one. A user list endpoint that fetches every column including profile_picture_blob and settings_json � both large fields � transfers megabytes of data for a response that only displays name and email. The database executes the query fast.

    The transfer time from database to application is slow. The serialisation of a 2 MB result set is slow. The JSON encoding of 200 full user objects is slow.

    The fix is always to select only the columns the endpoint actually returns to the client, and to paginate result sets. select(), Prisma's select, Hibernate's projections. The secondary problem is fetching entire relationship trees when only one field from the related record is needed.

    name requires loading the entire Customer record � address, phone, created_at, metadata � to display one field. Projections that fetch only the needed fields from joined tables are the solution. For large blobs and JSON columns, consider lazy loading or separate endpoints: load the lightweight list first, then fetch the heavy fields on demand.

    -- Bad: SELECT * fetches all columns including large BLOBs
    SELECT * FROM users WHERE status = 'active' LIMIT 100;
    
    -- Good: select only what the API response needs
    SELECT id, name, email, created_at
    FROM users
    WHERE status = 'active'
    LIMIT 100;
    
    -- Django: limit to specific fields
    users = User.objects.filter(status='active').values('id', 'name', 'email')[:100]
    
    -- Prisma: select specific fields
    const users = await prisma.user.findMany({
      where: { status: 'active' },
      select: { id: true, name: true, email: true },
      take: 100
    });
    
    -- Always add LIMIT for list endpoints � unbounded queries are a ticking clock
    -- A query returning 50 rows today may return 50,000 rows in 18 months

    Cause 4: Serial Database Calls That Could Be Parallel

    API endpoints frequently make multiple independent database queries in sequence when they could be issued in parallel. A user profile endpoint that fetches the user record, then their recent orders, then their notification preferences � three independent queries � runs them serially: 5ms + 5ms + 5ms = 15ms minimum, plus three round-trips. gather(), completing in approximately 5ms + one round-trip.

    The pattern is more subtle with ORMs: accessing multiple relationship properties on a model often triggers serial queries even when the framework could batch them. The fix requires identifying which queries are independent (no data dependency between them) and issuing them concurrently. In GraphQL, the DataLoader pattern solves both N+1 and serial loading by batching multiple field-level data requests into a single query.

    all() or async gather calls on independent data fetches is the straightforward solution.

    // Node.js: bad � three serial queries, 15ms+
    const user = await prisma.user.findUnique({ where: { id } });
    const orders = await prisma.order.findMany({ where: { userId: id } });
    const prefs = await prisma.preference.findUnique({ where: { userId: id } });
    
    // Node.js: good � three parallel queries, ~5ms
    const [user, orders, prefs] = await Promise.all([
      prisma.user.findUnique({ where: { id } }),
      prisma.order.findMany({ where: { userId: id }, take: 10 }),
      prisma.preference.findUnique({ where: { userId: id } })
    ]);
    
    # Python: bad � serial
    user = await db.fetch_one("SELECT * FROM users WHERE id = $1", user_id)
    orders = await db.fetch_all("SELECT * FROM orders WHERE user_id = $1", user_id)
    
    # Python: good � parallel
    user, orders = await asyncio.gather(
        db.fetch_one("SELECT id, name FROM users WHERE id = $1", user_id),
        db.fetch_all("SELECT id, total FROM orders WHERE user_id = $1 LIMIT 10", user_id)
    )

    Still Piecing This Together Yourself?

    A senior engineer looks at your actual setup, not a generic checklist, and tells you exactly what's wrong and how to fix it.

    Book a Discovery Call

    Network Overhead Before Your Code Ever Runs: DNS, TLS, and Protocol Choice

    Some of the latency blamed on the database never touches the database at all: it is spent negotiating the connection between the client and your API before a single line of your handler executes. 3 cut that to one round trip, and on a connection with 80ms of latency that alone is 80ms back. Session resumption removes another round trip for returning clients, and roughly 40% of HTTPS connections in the wild are resumptions rather than fresh handshakes, which is why keeping connections warm (HTTP keep-alive, connection pooling on the client side) matters as much as anything you do server-side.

    Protocol choice compounds this. HTTP/2 multiplexes multiple requests over one TCP connection, but TCP itself enforces head-of-line blocking: a single lost packet stalls every stream sharing that connection until it is retransmitted, regardless of which logical request the lost packet belonged to. HTTP/3 runs over QUIC instead of TCP, so a lost packet only stalls the one stream it belonged to, and independent testing has shown HTTP/3 cutting latency by more than half on lossy networks, mobile connections being the common real-world case.

    On a clean, low-latency network the gap narrows and can occasionally favor HTTP/2, since QUIC's userspace implementation is still catching up to decades of kernel-level TCP tuning, so this is a bigger lever for a mobile-heavy or global API than for an internal service on a fast private network.

    Related: our guide to data observability tooling

    Serverless Cold Starts: When the Runtime, Not the Database, Is the Delay

    If the slow endpoint runs on Lambda, Cloud Functions, or another FaaS platform, part of what looks like database latency can be the runtime booting up. NET cold starts have been measured as high as 1,739ms because of runtime and JIT compilation overhead. Java is the extreme case both ways: an unoptimised JVM cold start can run into several seconds, but AWS's SnapStart feature, which snapshots an initialised runtime and resumes it instead of booting fresh, brings that down to roughly 180 milliseconds.

    Rust and other compiled-to-native runtimes have been benchmarked as low as 16ms on arm64, because there is no interpreter or JIT to warm up at all. AWS states that fewer than 1% of invocations experience a cold start in practice, which is exactly why this failure mode is easy to miss in aggregate dashboards: it shows up as an occasional, hard-to-reproduce spike in p99 latency rather than a consistent slowdown, and it is worth ruling out explicitly before concluding the database is inconsistent for no reason.

    Garbage Collection Pauses: The Latency Spikes That Never Show Up in a Query Log

    js, Java, Go, Python with certain workloads) periodically stops the world to reclaim memory, and that pause shows up in your API's latency distribution with nothing in the database logs to explain it. A major garbage collection cycle commonly pauses execution for 50-200ms; on a high-throughput service, that is documented to be the difference between a p99 latency of 20ms and one of 200ms, entirely inside the application process, with the database uninvolved. This is why a p99 that runs 100x higher than your p50 is a specific signal worth chasing on its own: that ratio is characteristic of GC pauses, lock contention, or a stalled dependency, not gradual query slowdown, which tends to move p50 and p99 together.

    js, or switching JVM garbage collectors) has cut p99 latency by double-digit percentages without any code or database change; one streaming service reported a 40% p99 reduction purely from heap right-sizing and moving to a lower-pause-time collector. If your p99 spikes are periodic and roughly evenly spaced, correlate them against GC event timestamps before you correlate them against database metrics.

    Auth and Gateway Overhead: Where Milliseconds Hide Between the Client and Your Code

    Authentication and routing add real, measurable latency before your handler runs, and the amount varies enormously by approach. Locally validating a JWT (checking its signature against a cached public key) typically costs 5-10ms. Calling out to an external identity provider for OAuth token introspection on every request costs 50-100ms, because it is a network round trip to another service instead of a local computation, and that gap alone can double an otherwise-fast endpoint's response time if you are introspecting instead of validating locally with a cached key set.

    API gateway routing overhead itself is usually small: published traces breaking down a full request commonly show gateway routing costing around 2ms, with authentication, backend processing, and the database query accounting for the rest, which means a gateway is rarely the bottleneck unless it is doing more than routing (heavy request/response transformation, synchronous external auth calls). 2 seconds to 150ms by enabling a 60-second cache at the gateway, and combining a regional gateway with an edge CDN has been shown to cut p99 by 30-50% for geographically distant callers, independent of anything the origin server or database does differently.

    ORM-Specific Patterns That Kill Performance

    Each major ORM has idiomatic patterns that appear clean in code but generate catastrophically inefficient SQL:

    • Django: the most damaging patterns are accessing a related manager without prefetch_related (N+1), using .filter() on a many-to-many through model without select_related (N+1 + extra joins), and using .count() in a loop instead of aggregating

    • Rails: the N+1 pattern from lazy-loaded associations is endemic, and .where with a block (which fetches all records then filters in Ruby) is common

    • Prisma: the issue is accessing nested relations without include: each nested relation access triggers a separate query

    • SQLAlchemy: lazy-loaded relationships and the use of ORM objects instead of query result tuples adds serialisation overhead

    The single most impactful change in any ORM codebase is configuring strict N+1 detection in development. Django has nplusone. Rails has bullet. These libraries detect and log N+1 patterns during test runs and development, making them visible before they reach production.

    # Django: enable nplusone to catch N+1 in tests
    # pip install nplusone
    MIDDLEWARE = ['nplusone.ext.django.NPlusOneMiddleware']
    NPLUSONE_RAISE = True  # raise exception in tests, not just log
    
    # Rails: configure bullet in development
    # gem 'bullet', group: :development
    # config/environments/development.rb
    Bullet.enable = true
    Bullet.alert = true
    Bullet.rails_logger = true
    Bullet.add_footer = true  # shows N+1 alerts in page footer
    
    # Node.js/Prisma: log all queries to find N+1 manually
    const prisma = new PrismaClient({
      log: ['query'],  // log every query with timing
    })

    Related: our database engineering service

    Get a Straight Answer, Not a Sales Pitch

    Tell us what you're running into. We'll tell you directly what's causing it and what it takes to fix, before you sign anything.

    Book a Discovery Call

    Real Example: p99 from 4.2 Seconds to 180ms

    2 seconds. The endpoint loaded a list of transactions with associated merchant details, category labels, and user notes. Adding a read replica had no effect.

    2xlarge had no effect. Profiling the request with OpenTelemetry traces revealed: 187 database spans in a single request, each completing in 1-4ms, totalling approximately 400ms of database execution time. The remaining 3,800ms was round-trip latency and connection wait time.

    The endpoint was fetching a list of 150 transactions (1 query) and then loading merchant details per transaction (150 queries), category labels per transaction (150 queries), and user notes per transaction (150 queries): a classic N+1 pattern, three times over. The fix was three select_related / include calls to load all relationship data in the initial transaction fetch, collapsing 451 queries into 4. We also switched from SELECT * to selecting only the 8 fields the mobile app actually displayed.

    2 seconds to 180ms. The database instance remained unchanged. Total engineering time from profiling to production deploy: one day.

    This Pattern Is Public Record, Not Just Our Client Work

    This same misdiagnosis shows up repeatedly in incidents other teams have published themselves. 4 seconds just to acquire a connection from an exhausted pool, a number nobody found until they traced the wait, not the query. A separate, unrelated incident at an authentication provider in September 2025 saw latency spikes appear in one API but not a near-identical one running alongside it, which made the cause hard to triangulate at first; it eventually traced back to a routine minor-version PostgreSQL upgrade performed automatically by their cloud provider, which had quietly changed how that Postgres version handled internal connection locks.

    Neither incident was fixed by adding database capacity, and in both cases the database's own metrics (CPU, query time) looked completely healthy the entire time, which is precisely why teams default to disbelieving the database is the problem only after they have already spent hours proving it is not, instead of checking connection wait time and pool state first.

    When an API is slow, resist the instinct to add infrastructure before you have measured. The pattern of adding read replicas, cache layers, and larger instances before understanding the actual bottleneck is expensive and often ineffective. Start with a request trace that shows individual database spans with their execution times and counts.

    If the query count is high relative to the record count in the response, you have an N+1 problem. If individual query times are fast but connection acquisition time is slow, you have pool exhaustion. If you are fetching large columns you do not use, you have an over-fetching problem.

    If multiple queries run serially but have no data dependency, you have a parallelism opportunity. Each of these is fixable at the application layer with no infrastructure changes, and each fix compounds with the others. Fix the N+1, then the over-fetching, then parallelise the independent calls.

    A request that made 300 database round-trips in 2 seconds becomes 4 database calls in 50ms.

    Frequently Asked Questions

    Why is my API slow if the database queries are fast?

    When individual database queries complete in 1-5ms but API responses take 500ms or more, the problem is usually in the application layer: N+1 query patterns generating hundreds of database round-trips per request, connection pool exhaustion causing wait time before queries start, over-fetching (SELECT *) adding unnecessary data transfer and serialisation overhead, or multiple independent database calls being made serially instead of in parallel.

    What is an N+1 query problem?

    The N+1 query problem occurs when code fetches a list of N records and then issues one additional database query per record to load related data, producing N+1 round-trips instead of one. For example, loading 200 orders and then fetching each order's customer separately triggers 201 queries. The fix is to use eager loading: select_related() in Django, includes() in Rails, or include: { customer: true } in Prisma.

    How do I detect N+1 queries in production?

    Query pg_stat_statements and look for a query executing thousands of times per hour with near-identical structure but different parameter values, for example, 'SELECT * FROM customers WHERE id = $1' running 80,000 times in an hour. In development, use nplusone (Django) or bullet (Rails) to detect N+1 patterns before they reach production.

    What is connection pool exhaustion and how does it slow down my API?

    Connection pool exhaustion occurs when all pool connections are occupied and new requests queue waiting for one to become free. It appears as high API latency in APM tools even though the database queries themselves execute quickly: the time is spent waiting for a connection, not running the query. Monitor pool.waitingCount in Node.js or check pg_stat_activity for connections in the 'idle in transaction' state.

    How do I fix N+1 queries in Django, Rails, and Prisma?

    In Django: add select_related('field') for ForeignKey and prefetch_related('field') for ManyToMany. In Rails: add includes(:association) to the query chain. In Prisma: add include: { relationName: true } to findMany. In all cases, the ORM loads related data in the initial query instead of issuing one query per record during iteration. Enable nplusone or bullet in development to catch these before production.

    What's the difference between p50, p95, and p99 latency, and which one should I actually watch?

    p50 is the median request; half your traffic is faster, half slower. p99 is the slowest 1% of requests. A healthy p50 can hide a bad p99 that's still frustrating real users, since active users make enough requests to hit that slow 1% regularly. Percentiles also aren't additive: the combined p99 across two servers isn't the average of their individual p99s, so watch p50/p95/p99 segmented by route, region, and client rather than one blended number.

    Why does my average API latency look fine but users still complain?

    Averages get pulled toward the bulk of fast requests and hide the tail. If 1% of requests take 4 seconds while the rest take 100ms, the average can still look acceptable, but that 1% is enough to generate visible complaints, especially from your highest-traffic users who hit it most often. This is why the diagnostic steps in this article start with measuring the actual distribution, not the average.

    Do compression and caching actually fix a slow API?

    They help, but only after you've found the real bottleneck. Response compression typically cuts payload size substantially, and an in-memory cache (Redis, Memcached) in front of a repeated expensive query or computation can remove that work from the request path entirely. Neither fixes an N+1 pattern or pool exhaustion underneath; they just make the existing bottleneck less visible until traffic grows past what the cache absorbs.

    Does pagination improve API performance?

    Yes, for endpoints returning large result sets. Returning every row in one response forces the database to materialise the full set and the API to serialise and transfer all of it, even though the client only renders a page at a time. Cursor-based pagination bounds both the query cost and the payload size per request, which is why it matters more as a table grows, not less.

    Is a slow API always a backend or database problem?

    No. DNS resolution, TLS handshake overhead, and the HTTP protocol version in use all add latency before your application code runs at all. Auth middleware and API gateway steps (JWT validation, rate limiting, logging) add more on the way in. Serverless cold starts and garbage collection pauses can spike latency with no query involved. Isolate where the time actually goes before assuming the fix belongs in the backend.

    Get a Straight Answer on Your Setup

    Tell us what you're running into. We read every message personally and reply within 24 hours with times for a free call.

    We'll reply within 24 hours. Your information is never shared.