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.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.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.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.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.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.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.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
Measure First
Cache Aggressively
Optimize Queries
Use Async
Optimize Frontend
Scale Appropriately
Quick Reference
Latency Targets
Capacity Planning Formula
Interview Deep-Dive
Your application's p99 latency has degraded from 200ms to 2 seconds over the past month, but p50 is still fine at 80ms. What does this tell you, and how do you investigate?
Your application's p99 latency has degraded from 200ms to 2 seconds over the past month, but p50 is still fine at 80ms. What does this tell you, and how do you investigate?
- 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.
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.Explain cache invalidation strategies and tell me about a time caching went wrong -- either in your experience or a well-known production incident.
Explain cache invalidation strategies and tell me about a time caching went wrong -- either in your experience or a well-known production incident.
- 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.
Your team is debating between vertical scaling (bigger server) and horizontal scaling (more servers). What factors drive this decision, and when does each approach break down?
Your team is debating between vertical scaling (bigger server) and horizontal scaling (more servers). What factors drive this decision, and when does each approach break down?
- 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.
Walk me through how you would diagnose and fix an N+1 query problem that is causing a 5-second API response time.
Walk me through how you would diagnose and fix an N+1 query problem that is causing a 5-second API response time.
- 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')generatesSELECT 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')generatesSELECT * FROM ordersplusSELECT * 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
nplusonepackage, Rails’bulletgem, 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.