Skip to main content

Overview

Performance optimization is about making systems faster and more efficient. The key is measuring first, then optimizing the right things. Amazon found that every 100ms of added latency cost them 1% in sales. Google discovered that a half-second delay in search results caused a 20% drop in traffic. Performance is not a luxury — it directly impacts revenue, user retention, and operational costs. But the biggest trap in performance work is optimizing the wrong thing. Profile first, optimize second, measure third. Always.

Performance Metrics

Latency

  • Time to complete one request
  • Measure: p50, p95, p99
  • Target: < 200ms for web APIs

Throughput

  • Requests per second (RPS)
  • Transactions per second (TPS)
  • Target: Depends on scale

Availability

  • Uptime percentage
  • 99.9% = 8.76 hours downtime/year
  • 99.99% = 52 minutes/year

Resource Usage

  • CPU, Memory, Disk, Network
  • Cost efficiency
  • Bottleneck identification

Caching Strategies

Cache Levels

Caching Patterns

Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. The challenge with caching is not adding one — it is keeping the cache consistent with the source of truth. Every caching pattern makes different trade-offs between consistency, complexity, and performance.
Practical tip: Prefer cache invalidation (delete) over cache update (overwrite) on writes. If you update the cache and the DB write fails or rolls back, your cache now has data that does not exist in the database — a dangerous inconsistency. Deleting the cache entry and letting the next read repopulate is safer.

Cache Invalidation Strategies

Database Optimization

Query Optimization

The most impactful performance improvement in most applications is fixing bad queries. A single unoptimized query can consume more resources than the rest of your application combined. The two fundamental rules: let the database use its indexes, and never fetch data you do not need.

Indexing Strategy

Connection Pooling

Opening a new database connection involves TCP handshake, TLS negotiation, authentication, and session initialization — typically 20-50ms of pure overhead. For an API handling 1,000 requests per second, that is 20-50 seconds of wasted time every second. A connection pool maintains a set of pre-established connections that are borrowed and returned, reducing connection overhead to near zero.

Scaling Strategies

Vertical vs Horizontal Scaling

Load Balancing Algorithms

Database Scaling

Profiling & Benchmarking

Application Profiling

Profiling is the X-ray machine that reveals where your application actually spends its time. Without profiling, you are guessing — and developers are notoriously bad at guessing bottlenecks. Studies show that engineers correctly identify performance bottlenecks less than 30% of the time without measurement. The function you think is slow is almost never the actual problem.
Practical tip: Use flame graphs (generated by py-spy, async-profiler for Java, or pprof for Go) to visualize where time is spent. The width of each bar represents cumulative time. Wide bars at the top are your bottlenecks. Netflix uses flame graphs as their primary performance debugging tool across thousands of microservices.

Load Testing

N+1 Query Problem

The single most common performance killer in web applications. It is sneaky because the code looks clean and each individual query is fast — but the total number of queries is proportional to your data set size. With 100 orders, you make 101 queries. With 10,000 orders, you make 10,001 queries. The database is doing 10,000x more work than necessary.
How to detect N+1: Enable query logging in development and watch for repeated similar queries. Django Debug Toolbar, Rails bullet gem, and SQLAlchemy’s echo mode all surface this pattern. In production, look for endpoints where query count scales linearly with response size.

Async Processing

Background Jobs with Celery

The principle is simple: if the user does not need to wait for it, do not make them wait. Sending a confirmation email takes 2 seconds? The user should not stare at a spinner for 2 seconds — save the order, queue the email, and respond immediately. The email gets sent 2 seconds later in the background, and the user never notices.
Practical tip: Make background tasks idempotent (safe to retry). Messages can be delivered more than once if a worker crashes mid-processing. If send_email is called twice, the user should not receive two emails. Use deduplication keys or check-before-send logic.

Event-Driven with Message Queues

Frontend Performance

Critical Rendering Path

Optimization Techniques

Bundle Optimization

Every kilobyte of JavaScript you ship must be downloaded, parsed, and executed on the user’s device. On a mid-range Android phone on a 3G connection, 1MB of JavaScript can take 4-5 seconds to process. Bundle optimization directly impacts your Time to Interactive (TTI) metric and user experience.

Database Performance

Query Optimization Checklist

Slow Query Analysis

Slow query logs are the single most valuable tool for database performance. Enable them from day one, not after you have a performance crisis. Most databases have a built-in slow query log — use it.
Practical tip: Run EXPLAIN ANALYZE on every query in your critical path before deploying to production. A query that runs in 5ms on your dev database with 1,000 rows may take 5 seconds on production with 10 million rows. The execution plan will reveal whether it will scale.

Connection Pool Sizing

Getting pool size right is surprisingly important. Too few connections and requests queue up waiting for a connection (increased latency). Too many connections and the database spends more time context-switching between connections than doing useful work (decreased throughput for everyone). PostgreSQL’s official guidance: more connections is NOT always better.
Practical tip: If your application has more pool connections than your database can efficiently handle, use a connection pooler like PgBouncer between your app and PostgreSQL. PgBouncer multiplexes hundreds of application connections through a smaller number of actual database connections.

Application-Level Optimization

Async I/O

The key insight: most web application time is spent WAITING — waiting for database responses, waiting for API calls, waiting for file reads. With synchronous code, each wait blocks the entire thread. With async I/O, the thread does useful work while waiting, dramatically improving throughput. The analogy: a synchronous waiter takes one order, walks to the kitchen, waits for the food, delivers it, then takes the next order. An async waiter takes all orders, sends them to the kitchen at once, and delivers food as it comes out.

Efficient Data Structures

Choosing the right data structure is often a bigger win than any algorithmic optimization. Using a list where you need a set turns an O(1) lookup into an O(n) scan. At 1 million items, that is the difference between 1 microsecond and 1 second.
Practical tip: Before reaching for complex algorithms, check if switching data structures solves the problem. Replacing if item in my_list (O(n)) with if item in my_set (O(1)) is a one-line change that can eliminate entire performance bottlenecks.

Performance Testing Strategy

Types of Tests

Key Metrics to Track

Optimization Checklist

1

Measure First

Never optimize without data. Profile your application, identify bottlenecks using APM tools (New Relic, Datadog, Jaeger).
2

Cache Aggressively

Add caching at every level - browser, CDN, application, database. Use cache-aside pattern with appropriate TTLs.
3

Optimize Queries

Use EXPLAIN ANALYZE, add proper indexes, avoid N+1 queries, use connection pooling.
4

Use Async

Don’t block on I/O. Use async/await, message queues, background jobs for long-running tasks.
5

Optimize Frontend

Minimize bundle size, lazy load, use CDN, optimize images, implement proper caching headers.
6

Scale Appropriately

Start with vertical scaling (simpler), move to horizontal when needed. Use auto-scaling.

Quick Reference

Latency Targets

Capacity Planning Formula

Remember: “Premature optimization is the root of all evil” — Donald Knuth. But read the full quote: “We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” The wisdom is not “never optimize” — it is “optimize the right 3%, and identify it through measurement, not guesswork.”
Common Mistake: Optimizing based on assumptions. The code you think is slow is rarely the actual bottleneck. Always profile first, optimize the actual hot paths, and verify improvements with benchmarks. A common trap: spending a week optimizing an algorithm from O(n log n) to O(n) when the real bottleneck is an unindexed database query that takes 1000x longer than the algorithm.

Interview Deep-Dive

Strong Answer:
  • The divergence between p50 and p99 is the key signal. If p50 is fine, the system works well for most requests. But 1 in 100 requests is 10x slower. This pattern points to a specific subset of requests hitting a slow path — not a systemic degradation.
  • Common causes of p99 spikes with stable p50: (1) Garbage collection pauses — GC in Java or Go can introduce 100-500ms pauses that only affect requests unlucky enough to coincide with a GC cycle. Check GC logs for stop-the-world pause frequency and duration. (2) Database query plan regression — a small percentage of queries may be hitting a full table scan due to parameter sniffing or stale statistics, while most queries use the index correctly. Check PostgreSQL’s pg_stat_statements for queries with high variance in execution time. (3) Downstream service timeouts — if your service calls an external API that is intermittently slow, the p99 reflects those timeout cases. Check distributed traces for spans with high p99. (4) Connection pool exhaustion — under peak load, some requests wait for a database connection while the pool is fully utilized. The pool_timeout setting determines how long they wait. (5) Lock contention — if a subset of requests compete for the same rows (hot keys), they serialize through the lock.
  • My investigation steps: Pull p99 traces from Jaeger or Datadog and examine 10-20 slow requests. Find the common thread — is it always the same endpoint, the same database query, the same downstream service, or the same time of day? Correlate with deployment history: did p99 start degrading after a specific release? Check if the data set grew significantly (more rows, larger payloads) over the past month.
  • The gradual degradation over a month is a strong signal of data growth. A query that was fast on 1 million rows may hit a tipping point at 10 million rows where the query planner switches from an index scan to a sequential scan because the index is no longer selective enough.
Follow-up: You discover the p99 issue is caused by the database. 1% of queries are scanning a partition with 50 million rows while 99% hit a smaller partition with 500K rows. How do you fix this without rewriting the application?This is a classic data skew problem. The immediate fix: create a partial index specifically for the large partition. If the slow queries filter on WHERE tenant_id = 'large_customer', create CREATE INDEX idx_large_tenant ON orders(created_at) WHERE tenant_id = 'large_customer'. This targets the exact queries causing the p99 spike without adding index overhead to the other 99% of writes. For a longer-term fix, implement table partitioning: split the orders table by tenant_id (list partitioning) or by date range (range partitioning). The large customer’s data lives in its own partition, which the planner handles independently. Partition pruning ensures queries that do not touch the large partition never even look at it. If partitioning is too invasive, consider a materialized view for the common query patterns on the large partition — precompute the expensive aggregation and refresh it on a schedule.
Strong Answer:
  • Cache invalidation is hard because you are maintaining two copies of truth (cache and database) and they can diverge in subtle ways. The three main strategies are: (1) TTL-based — set an expiration time, accept that data may be stale for up to that duration. Simple, reliable, but you cannot control when updates become visible. (2) Event-based — invalidate the cache whenever the source data changes. Low staleness, but you need a reliable event delivery mechanism and must handle race conditions. (3) Write-through — update cache and database together on every write. Cache is always fresh for reads, but adds latency to writes.
  • A well-known production incident: Facebook’s Memcache thundering herd. When a cached value expires, thousands of concurrent requests simultaneously discover the cache miss and all hit the database to repopulate it. The database, expecting to handle 10 requests per second for that key, suddenly gets 10,000 simultaneous queries for the same data. This can cascade into a database overload. Facebook solved this with lease-based locking: when a cache miss occurs, the first request gets a “lease” (a lock) to fetch from the database. All other requests wait briefly for that first request to populate the cache, then read from cache. Only one database query executes instead of 10,000.
  • Another common failure mode: race conditions during cache invalidation. Request A reads value X from the database. Between the read and the cache write, Request B updates X to Y in the database and invalidates the cache. Request A then writes the stale value X into the cache. The cache now contains X while the database contains Y, and it stays wrong until TTL expires. Prevention: use cache-aside with delete-on-write (not update-on-write) — the next read after the delete will fetch fresh data.
  • My practical guideline: always set a TTL even on event-invalidated caches. The TTL is your safety net — if the invalidation event is lost (network partition, message queue issue), stale data eventually expires rather than living in cache forever. I have seen systems where a failed invalidation event caused a cache to serve stale data for weeks because there was no TTL.
Follow-up: You are caching user profiles with a 1-hour TTL. A user updates their display name and immediately refreshes the page but sees the old name. How do you solve this without removing the cache?This is a read-your-writes consistency problem. Several solutions without removing caching: (1) After the update, invalidate the cache entry for that specific user. The next read triggers a cache miss and fetches fresh data. Cost: one extra database read, which is acceptable since profile updates are infrequent. (2) Write-through on update: when the user updates their profile, update both the database and the cache atomically. The cache is immediately fresh for the next read. (3) Client-side optimistic update: the frontend immediately shows the new name from the form submission data, without waiting for a round-trip to the backend. Even if the backend read hits a stale cache, the user sees the correct name in their UI. (4) Read-after-write routing: for a short window after a write (say 5 seconds), route that user’s reads to the primary database bypassing the cache entirely. This is the pattern DynamoDB uses for strongly consistent reads. I prefer option 1 (invalidate on write) for most cases because it is simple, reliable, and handles the common case without adding complexity.
Strong Answer:
  • Vertical scaling means adding more CPU, RAM, or faster disks to a single server. Horizontal scaling means adding more servers and distributing the load. The decision is not ideology — it is engineering economics driven by the specific workload.
  • Vertical scaling advantages: simplicity. No distributed systems complexity — no network partitions, no data synchronization, no load balancer configuration, no distributed debugging. Your existing code works unchanged. A single modern server with 128 cores, 1TB RAM, and NVMe SSDs can handle an enormous amount of work. Stack Overflow serves 1.3 billion page views per month from just 9 web servers and 4 SQL servers because they optimized vertically first.
  • Vertical scaling breaks down when: (1) You hit the hardware ceiling — there is a maximum server size available from cloud providers (e.g., AWS’s x1e.32xlarge with 128 vCPUs and 4TB RAM). If your workload exceeds that, you must go horizontal. (2) You need fault tolerance — a single server is a single point of failure. If it dies, everything is down. (3) Your workload is embarrassingly parallel (web requests, map-reduce) and scales linearly with more nodes.
  • Horizontal scaling advantages: near-infinite theoretical capacity (add more nodes), fault tolerance (lose one node, others continue), and geographic distribution (servers in multiple regions for lower latency).
  • Horizontal scaling breaks down when: (1) Your workload requires strong consistency across nodes — distributed consensus (Raft, Paxos) adds latency and complexity. (2) The coordination overhead exceeds the benefit — 100 servers each running at 5% utilization is more expensive and harder to manage than 5 servers at 100%. (3) Your data has hot spots that cannot be sharded evenly — one shard gets 80% of traffic while others sit idle.
  • My decision framework: start vertical (simpler, cheaper, faster to implement). Add monitoring. When monitoring shows you are approaching the limits of the current server (CPU > 80% sustained, or memory is fully utilized), evaluate whether the next size up is cost-effective. When vertical scaling becomes more expensive than horizontal (usually around the 4-8 core range for compute-bound, or 64-128GB for memory-bound), or when you need fault tolerance, transition to horizontal.
Follow-up: You have horizontally scaled your stateless web tier to 10 instances behind a load balancer. But your single PostgreSQL database is now the bottleneck. What are your options?This is the classic “your database does not scale horizontally” problem. Options in order of complexity: (1) Read replicas — if your workload is 90% reads (common for most web apps), add 2-3 read replicas and route read queries to them. Writes still go to the primary. This can give you 3-4x read throughput with minimal application changes. (2) Connection pooling with PgBouncer between app and database — many applications exhaust the database’s max_connections before the database CPU or I/O is saturated. PgBouncer multiplexes hundreds of application connections through fewer database connections. (3) Query optimization and caching — often the database is not genuinely overloaded; it is doing unnecessary work. Add Redis caching for hot queries, fix N+1 problems, add missing indexes. This can give you 10-100x improvement. (4) Vertical scaling of the database — a larger RDS instance (32 cores, 256GB RAM) with provisioned IOPS. Databases benefit enormously from vertical scaling because more RAM means larger buffer cache means fewer disk reads. (5) Sharding — split data across multiple database instances by a shard key (tenant_id, user_id). This is the nuclear option: it gives you near-linear horizontal scaling but adds enormous application complexity (cross-shard queries, distributed transactions, rebalancing). Only pursue this after exhausting options 1-4. Companies like Instagram ran a single PostgreSQL instance until they reached 30 million users.
Strong Answer:
  • First, I need to confirm it is actually an N+1 problem. I would enable query logging (Django Debug Toolbar, Rails bullet gem, or SQLAlchemy’s echo=True) and look at the API endpoint’s query log. The signature of N+1 is unmistakable: one initial query (SELECT * FROM orders WHERE user_id = 123) followed by hundreds of nearly identical queries (SELECT * FROM users WHERE id = 1, SELECT * FROM users WHERE id = 2, … repeated for every order). If I see 501 queries for an endpoint that returns 500 orders, that is the problem.
  • The root cause is the ORM’s lazy loading behavior. When you access order.user.name, the ORM fires a separate query to load the user for that specific order. This is fine for a single order, but in a loop over 500 orders, you get 500 individual queries instead of one batch query.
  • The fix depends on the relationship type. For a foreign key relationship (order belongs_to user): use eager loading with a JOIN. In Django: Order.objects.select_related('user') generates SELECT orders.*, users.* FROM orders JOIN users ON orders.user_id = users.id. One query instead of 501. In SQLAlchemy: query.options(joinedload(Order.user)). In Rails: Order.includes(:user).
  • For a many-to-many relationship (order has_many items): use prefetch loading, which fires two queries instead of N+1. In Django: Order.objects.prefetch_related('items') generates SELECT * FROM orders plus SELECT * FROM order_items WHERE order_id IN (1, 2, ..., 500). Two queries total.
  • The performance impact is dramatic. 500 individual queries at 5ms each = 2.5 seconds of database time. One query with a JOIN = 10-20ms. That is a 100-200x improvement from a one-line ORM change.
  • Prevention: I would add a development-time N+1 detector. Django’s nplusone package, Rails’ bullet gem, or SQLAlchemy’s event listeners can raise warnings or errors when N+1 patterns are detected during development and testing, preventing them from reaching production.
Follow-up: You fix the N+1 with eager loading, but now the single JOIN query returns a huge result set because each order has 20 items. The response payload is 50MB. What do you do?This is the “eager loading causes a Cartesian explosion” problem. If 500 orders each have 20 items and you use a JOIN, the result set is 10,000 rows with duplicated order data in every row. Three solutions: (1) Use subquery loading (prefetch) instead of JOIN loading. This fires 2 queries (one for orders, one for items filtered by the order IDs) and assembles them in application memory. No Cartesian explosion. (2) Implement pagination — do not return 500 orders at once. Return 20 per page with cursor-based pagination. Now even the worst case is 20 orders with 20 items each = 400 rows, which is manageable. (3) Use a GraphQL-style approach where the client specifies which fields it needs. If the client only needs order ID and total (not the full item list), do not load items at all. The key principle: solve the N+1 problem with the lightest-weight loading strategy that fits your data shape, not by always reaching for JOINs.