REST API latency spikes from N+1 queries and PHP GC — how to diagnose and fix
By Kevin Marcondes · Published on · 7 min read
REST API latency spikes caused by N+1 queries and PHP garbage collection: diagnosis and practical fixes. If your endpoints show intermittent p95/p99 peaks, this guide explains how to spot the symptoms, prove the N+1, measure GC impact, and apply fixes in code, database and PHP‑FPM — always validating changes with metrics and tracing.
Typical symptoms: how to spot latency spikes in production
Spikes appear at random times without proportional traffic increase. p95/p99 climbs while the average barely moves. Listing and reporting endpoints are usually hit hardest.
Logs show dozens or hundreds of similar SELECTs within a single request. In PHP‑FPM, some workers become significantly slower and get recycled due to memory pressure.
The database shows short bursts of QPS with repeating patterns. The app CPU isn’t saturated, but responses pause — a classic pattern when N+1 queries combine with GC pauses.
- Jagged, unpredictable p95/p99
- Many similar queries per request
- PHP‑FPM workers “bloating” and being recycled
- Listing/relationship endpoints disproportionately affected
Why N+1 and GC produce intermittent latency
N+1 happens when code loads a collection and, for each item, issues another query. With lazy-loading in ORMs this can go unnoticed and multiply I/O.
PHP’s GC runs inside the request. Large objects and arrays that form reference cycles trigger collection. At certain points the collector pauses execution to scan structures.
Combined, loops create many objects, each iteration performs I/O, and eventually GC runs. The request stretches and becomes a p95/p99 spike.
Observable diagnosis: logs, correlation, tracing and slowlog
Make requests traceable end-to-end. Use structured logs with a correlation ID, counters for queries and serialization time. See how to implement this in the structured JSON logs and correlation guide: structured JSON logs and request correlation.
Add tracing on critical routes to break down time into application, SQL and network spans. This helps pinpoint N+1 bursts and GC pauses. See Distributed Tracing guidance: distributed tracing with OpenTelemetry in PHP.
Enable PHP‑FPM slowlog to capture stacks above a threshold and review code bottlenecks. In the database, collect EXPLAIN plans for the most repeated queries.
- Structured logs and correlation IDs
- Tracing with spans for app, DB and serialization
- PHP‑FPM slowlog with request_slowlog_timeout
- EXPLAIN samples for “hot” queries
How to unmistakably spot an N+1
Count queries per request and group them by SQL fingerprint. If the same statement runs hundreds of times for an endpoint, it’s an N+1.
In the ORM, look for lazy-loading inside loops. In frameworks like Laravel, enable eager loading on affected relations and verify the query count drops.
Validate with tracing: instead of many short DB spans, you want a few well-designed queries (backed by proper indexes).
- Metric: queries per request per endpoint
- Repeated SQL fingerprints within the same request
- Use eager loading / joins for relations accessed in loops
- EXPLAIN to verify plans and index usage
Measuring and mitigating PHP GC impact
Monitor memory per request and identify points of abrupt growth. Large temporary objects and nested arrays commonly trigger GC.
Reduce cycles: avoid unnecessary cross-references, free large structures early (unset), and consider generators to process streams without materializing everything.
In targeted and measured blocks, toggling GC off and on can reduce pauses. For deeper stability work on the runtime, see our guide on PHP‑FPM memory leaks: PHP‑FPM memory leaks.
- Free large buffers early
- Avoid creating reference cycles
- Use generators/yield for large pipelines
- Recycle workers with pm.max_requests
Practical fixes and quick wins: code to database
Attack N+1 with focused rewrites: replace per-item queries with joins and eager loading. For very hot APIs, cache hot results with short TTLs and business-key invalidation.
Optimize repeated queries with proper indexes and by reducing selected columns. In serializers, avoid mapping huge objects unless necessary. See general approaches in how to reduce REST API latency: how to reduce REST API latency. Validate every change by monitoring p95/p99 and query counts.
| Cause | Observable symptom | Quick fix | How to validate |
|---|---|---|---|
| N+1 on 1:N relation | Hundreds of identical SELECTs per request | Eager loading / joins; fetch IDs in batches | Queries per request fall; p95 stabilizes |
| Unnecessary fields | Large payload and slow serialization | Select only needed columns (projected SELECT) | Serialization time decreases; smaller response size |
| GC on massive arrays | Pauses in loops; high memory usage | Process in chunks / use generators; free buffers | Memory per request drops; pauses disappear |
| Unstable worker | Latency grows over time | Tune pm.max_requests; review memory_limit | Workers recycle before “bloating”; p99 drops |
PHP‑FPM tuning for latency predictability
Right-size pm.max_children for CPU and DB capacity to avoid overwhelming connections. Set request_terminate_timeout and slowlog to catch stuck requests.
Use pm.max_requests to recycle processes after N requests, reducing fragmentation and cumulative memory effects. Monitor recycling rate to avoid thrashing.
Export FPM status (pm.status_path) and build dashboards for queue length, average time, p95/p99 and memory. Correlate these with database metrics to reveal backpressure.
- pm=dynamic or static depending on load profile
- request_slowlog_timeout and slowlog enabled
- pm.max_requests calibrated to memory patterns
- pm.status_path exported and monitored
Validation and continuous monitoring after fixes
Set SLOs for latency per endpoint and track histograms (p50/p95/p99). Commit dashboards and alerts to the repo for traceability.
Roll out changes gradually and compare before/after using tracing and logs. If needed, use feature flags to switch query or serializer versions safely.
Regularly review the most expensive endpoints and reassess indexes when access patterns change. For SQL tuning, also check PostgreSQL guides: PostgreSQL query optimization for ERP reports.
- SLOs per route and alerts on sustained p99
- A/B comparison over time windows
- Periodic review of the costliest endpoints
Frequently asked questions
How do I tell N+1 apart from a missing index?
With N+1 you’ll see the same query run dozens of times within a single request. Missing indexes produce few queries but each is slow. Count queries per request, group by fingerprint and run EXPLAIN to check for full scans or poor plans.
Does disabling PHP's garbage collector stop spikes?
It can reduce pauses in specific, measured blocks, but it’s not a universal fix. The GC reclaims cycles; disabling it globally raises memory usage and risks crashes. Prefer optimizing allocations and recycling workers with pm.max_requests.
Does pm.max_requests help latency stability?
Yes. Recycling processes after N requests reduces fragmentation and cumulative memory effects. Avoid overly frequent recycling. Monitor p95/p99 and memory per worker to tune the value.
How can I prove I fixed the N+1?
Before/after data should show fewer queries per request and stabilized p95/p99. Tracing will show fewer DB spans. Structured logs should record a sustained drop in query counters per route.
Will caching the entire JSON response solve it?
Caching can blunt spikes but doesn’t replace fixing the root cause. Use short TTLs and business-key invalidation. Fix the N+1 at the source (eager loading, joins) and use cache as a complementary optimization.
Want to stabilize your REST API latency?
If you’re seeing spikes caused by N+1 queries and PHP GC, we can analyze your critical endpoints, measure root causes and propose practical fixes.
Let's talkSee also
- How to recover corrupted statistics and indexes in PostgreSQL that caused slow queries
- Postgres 'Too many connections' at billing peak — practical diagnosis and remediation guide
- Reduce costs migrating Magento to OpenMage with a rollback plan and acceptance tests
- Practical guide: recover a dropped PostgreSQL table with point-in-time recovery without downtime