API latency optimization: from 800 ms to 250 ms, step by step 30 questions in 5 chapters
- 01Phase 1: Measure first4 questions
- 02Phase 2: Optimize18 questions
- 03Phase 3: Verify
- 04Interview questions6 questions
- 05Sources
Phase 1: Measure first
1.1 Set a baseline (k6, JMeter or Locust; p50, p95, p99 and throughput) Medium 1 min
Before changing anything, I run a load test with a tool like k6, JMeter or Locust and record p50, p95, p99 and throughput at a known request rate. Averages hide the slow tail that real users feel, so percentiles are the baseline. Every later optimization is compared against this same test, so I can prove the change actually helped and did not just move the problem.
Read the full answer1.2 Find the bottleneck with APM traces Medium 1 min
I open distributed traces from an APM or OpenTelemetry for the slow requests and look at the spans. Each span shows where time went: the database, our own code, a downstream HTTP call, or waiting for a connection. That tells me which layer to fix first, instead of guessing. I focus on the longest span on the critical path, because shortening anything else barely changes total latency.
Read the full answer1.3 Find slow queries: pg_stat_statements and EXPLAIN (ANALYZE, BUFFERS) Hard 1 min
I use the pg_stat_statements extension to rank queries by total and mean execution time, because a medium query called thousands of times can matter more than one slow query. Then I run EXPLAIN with ANALYZE and BUFFERS on the worst ones. That shows the real plan, actual row counts versus estimates, and how many pages came from cache or disk.
Read the full answer1.4 Check database health (seq_scan, dead tuples, lock waits, buffer hit ratio) Hard 1 min
I check the PostgreSQL statistics views for tables with many sequential scans, a lot of dead tuples, lock waits, and a low buffer cache hit ratio. Each points to a different fix: a missing index, vacuum not keeping up, contention between transactions, or not enough memory. A high sequential scan count on a small table is normal, so I always read it together with table size.
Read the full answer
Phase 2: Optimize
2.1 Composite indexes: equality columns first, then range Hard 1 min
In a B-tree composite index, I put the columns used with equality first and the range column last. Then the database jumps straight to one contiguous slice of the index and reads only matching rows. If the range column comes first, it has to scan the whole range and filter the rest. Column order should follow how the query filters, not the table definition.
Read the full answer2.2 Partial indexes for hot subsets Medium 1 min
A partial index only covers rows that match a condition, for example only orders whose status is pending. If most queries hit that small hot subset, the index is much smaller, fits in memory, and is cheaper to maintain on writes. The catch is that the query's WHERE clause must clearly imply the index condition, otherwise the planner cannot use it.
Read the full answer2.3 Covering indexes with INCLUDE (index-only scans) Hard 1 min
A covering index stores extra columns with the INCLUDE clause, so the database can answer the query from the index alone, which is an index-only scan, without visiting the table. That saves a lot of random reads on hot queries. It only works well when the visibility map is current, so vacuum matters, and every extra column makes the index bigger and writes slower.
Read the full answer2.4 Fresh statistics (ANALYZE) and autovacuum tuning against bloat Hard 1 min
The planner chooses plans from table statistics. If they are stale after big data changes, it misjudges row counts and picks bad plans, so I run ANALYZE or let autovacuum do it. Autovacuum also cleans dead rows left by updates and deletes. On busy tables I tune it to run more often, otherwise bloat grows and every scan gets slower.
Read the full answer2.5 Read replicas or partitioning for heavy tables Hard 1 min
When reads overload the primary, I send read-only traffic to replicas, accepting a little replication lag for that data. When a single table becomes very large, partitioning by time or key lets queries skip whole partitions and makes deleting old data cheap. Partitioning is not a general speed-up; it helps mainly with very large tables and queries that filter on the partition key.
Read the full answer2.6 No SELECT * Easy 1 min
I select only the columns I need instead of everything. That means less data read, sent over the network and turned into objects, and it allows index-only scans when the selected columns are in an index. It also protects the API from accidentally exposing new columns and from breaking when someone adds a large text or binary column later.
Read the full answer2.7 Don't wrap indexed columns in functions in WHERE Medium 1 min
If I wrap an indexed column in a function in the WHERE clause, like lowercasing an email, a normal index on that column cannot be used and the database scans the table. I either rewrite the condition so the column stays bare, for example using a date range instead of extracting the date, or I create an expression index that matches the function exactly.
Read the full answer2.8 Keyset pagination instead of OFFSET Medium 1 min
OFFSET pagination makes the database read and throw away every skipped row, so deep pages get slower and slower. Keyset pagination remembers the last sort key from the previous page and asks for rows after it, using an index. Each page then costs about the same. The trade-off is that users cannot jump to an arbitrary page number, only next and previous.
Read the full answer2.9 EXISTS instead of IN for large subqueries Medium 1 min
For a large subquery, EXISTS can stop at the first match per row, while a badly planned IN might build the whole list first. Modern PostgreSQL often plans both as the same semi-join, so I check EXPLAIN instead of assuming. The bigger trap is NOT IN with a subquery that can return nulls, because it silently returns no rows; NOT EXISTS avoids that.
Read the full answer2.10 Multi-row INSERT or COPY instead of one row at a time Easy 1 min
Inserting rows one at a time pays a network round trip and often a commit for each row. A multi-row INSERT or a batch sends many rows in one statement, and COPY is the fastest way to bulk load in PostgreSQL. For large loads I also wrap everything in one transaction, and sometimes drop and recreate indexes afterwards, which the PostgreSQL docs recommend.
Read the full answer2.11 Remove N+1 queries (fetch joins or batch fetching) Medium 1 min
The N+1 problem is one query for a list and then one extra query per item, usually from lazy loading in JPA. I spot it in SQL logs or traces as many near-identical queries. I fix it with a fetch join or entity graph for that use case, or with batch fetching so related rows load in groups, and I add a test that checks the query count.
Read the full answer2.12 Connection pooling (HikariCP, PgBouncer) Hard 1 min
Opening a database connection is slow, so I reuse connections from a pool like HikariCP, which is the Spring Boot default. The pool should be small and sized for the database, not for the number of threads, because too many active connections compete for the same CPU and disk. PgBouncer helps when many application instances together would open too many connections.
Read the full answer2.13 Redis cache-aside for read-often, change-rarely data Medium 1 min
For data that is read often and changes rarely, I use cache-aside with Redis: read from the cache, and on a miss load from the database and store it with a time to live. On updates I write to the database and then delete the cache entry. The risks are stale data, a stampede when a hot key expires, and extra moving parts.
Read the full answer2.14 Make independent calls in parallel Hard 1 min
If a request calls several services or queries that do not depend on each other, running them in sequence adds their latencies together. Running them in parallel, for example with CompletableFuture or virtual threads, makes the total close to the slowest call. I always add a timeout per call and use a bounded executor, so one slow dependency cannot hold every thread.
Read the full answer2.15 Paginate and return only needed fields Easy 1 min
I never return unbounded lists. Every collection endpoint is paginated with a sensible maximum page size, and the response includes only the fields the client needs. Smaller responses mean less database work, less serialization, and less network time, which matters most for mobile clients. Field selection or separate summary and detail endpoints keep payloads small without forcing clients to make extra calls.
Read the full answer2.16 Compress with gzip or brotli Easy 1 min
For larger text responses like JSON, I enable gzip or brotli compression on the server or the load balancer. Text compresses very well, so transfer time drops a lot, especially on slow networks. I set a minimum size so tiny responses are not compressed, because the CPU cost is not worth it, and I skip already compressed content such as images.
Read the full answer2.17 Cache with ETag and Cache-Control Medium 1 min
For responses that do not change often, I send Cache-Control headers so clients and CDNs can reuse them, and an ETag that identifies the version. The client then sends a conditional request with If-None-Match, and if nothing changed the server replies 304 Not Modified with no body. That saves bandwidth and serialization, though the server may still need to compute the version.
Read the full answer2.18 Move slow work to a background queue Medium 1 min
If work like sending emails, generating reports or calling slow partners does not need to finish before the response, I put it on a queue and process it in the background. The API answers fast, often with 202 Accepted and a status link. Background work must be idempotent and retried safely, and the client needs a way to see when it is done.
Read the full answer
Phase 3: Verify
3.1 Rerun the same load test against the baseline Easy 1 min
After the changes, I rerun exactly the same load test, with the same data, request rate and environment, and compare p50, p95, p99, error rate and throughput with the baseline. Only that comparison proves the improvement. If I changed the test, the numbers would not be comparable, so the test setup is treated as part of the experiment.
Read the full answer3.2 Dashboards and alerts for regressions Easy 1 min
I keep dashboards for the four golden signals: latency, traffic, errors and saturation, with latency shown as percentiles. Alerts fire on symptoms users feel, like p99 above the target for several minutes, not on every CPU blip. A performance check in CI or before release catches regressions before production, so the latency win does not slowly disappear.
Read the full answer
Interview questions
4.1 How do you make sure an application meets its performance requirements? Medium 1 min
I start by agreeing on a concrete target as percentiles, for example p99 under a set time at a given request rate. Then I write a load test for that target and run it before each release. In production I watch latency, traffic, errors and saturation with alerts on p99. When we miss the target, I use traces to find where time goes before changing anything.
Read the full answer4.2 Your API p99 went from 200 ms to 2 s. What do you do? Hard 1 min
First I ask what changed: a deploy, a traffic spike, or a data change. I check whether it is all endpoints or one, all pods or one. Then I open slow traces to see whether time is in the database, our code, or a downstream call. If a deploy caused it, I roll back first and investigate after, because users come before root cause.
Read the full answer4.3 Why percentiles and not the average? Easy 1 min
Latency is skewed. A few very slow requests barely move the average, but real users hit them, and one page often makes many calls, so most users see the tail at some point. Percentiles like p95 and p99 show what the slowest users actually experience. An average can look healthy while a meaningful share of requests are painfully slow.
Read the full answer4.4 How do you find and fix an N+1 problem? Medium 1 min
I look at SQL logs or traces and see one query for the list followed by one query per item, which is the N+1 pattern. I fix it with a fetch join or an entity graph for that use case, or batch fetching so related rows load in groups. Then I add a test that asserts the number of queries, so it does not come back.
Read the full answer4.5 When do you add a cache, and what are the risks? Hard 1 min
I add a cache when data is read often, changes rarely, and the database is the measured bottleneck. I use cache-aside with a time to live and delete entries on updates. The risks are stale data, a stampede of requests when a hot key expires, memory growth without eviction, and one more system to run, so I measure the hit rate.
Read the full answer4.6 OFFSET pagination is slow on page 5,000. Why, and how do you fix it? Medium 1 min
With OFFSET, the database still reads and throws away all the skipped rows, so a deep page means scanning a huge number of rows just to return a few. The fix is keyset pagination: remember the last sort key and ask for rows after it, with an index on the sort columns. Every page then costs about the same.
Read the full answer
Sources
- k6 thresholds
- Apache JMeter user manual
- Locust docs
- Google SRE book: Service Level Objectives
- Google SRE book: Monitoring Distributed Systems
- Gil Tene, "How NOT to Measure Latency"
- OpenTelemetry: Traces
- OpenTelemetry Java agent
- PostgreSQL 17: pg_stat_statements
- PostgreSQL 17: EXPLAIN
- PostgreSQL 17: Using EXPLAIN
- PostgreSQL 17: Cumulative Statistics System
- PostgreSQL 17: pg_locks
- PostgreSQL 17: Multicolumn Indexes
- PostgreSQL 17: Partial Indexes
- PostgreSQL 17: Index-Only Scans and Covering Indexes
- PostgreSQL 17: ANALYZE
- PostgreSQL 17: Routine Vacuuming
- PostgreSQL 17: Autovacuum settings
- PostgreSQL 17: Hot Standby
- PostgreSQL 17: Table Partitioning
- PostgreSQL 17: Indexes on Expressions
- PostgreSQL 17: LIMIT and OFFSET
- PostgreSQL 17: Subquery Expressions
- PostgreSQL wiki: Don't Do This
- PostgreSQL 17: Populating a Database
- PostgreSQL 17: COPY
- PostgreSQL 17: INSERT
- pgJDBC connection properties
- Use The Index, Luke: Greater, Less and BETWEEN
- Use The Index, Luke: Index-Only Scan
- Use The Index, Luke: Functions
- Use The Index, Luke: Paging Through Results
- Hibernate 6.6 User Guide: Fetching
- HikariCP README
- HikariCP wiki: About Pool Sizing
- PgBouncer config
- Microsoft Azure Architecture: Cache-Aside pattern
- Redis: Caching solutions
- Redis: Client-side caching
- Redis EXPIRE
- Java 21 CompletableFuture
- JEP 444: Virtual Threads
- RFC 9110: HTTP Semantics
- RFC 9111: HTTP Caching
- RFC 7932: Brotli
- Spring Boot: Enable HTTP response compression
- Spring: HTTP Caching
- Apache Kafka intro