Nginx Performance Tuning in Practice: gzip, Caching, and Connection Pool Configuration
Last week, I received an alert that an e-commerce website’s homepage load time had spiked to 4 seconds. Opening Chrome DevTools revealed the issue: 120KB HTML file, plus 350KB for CSS and JavaScript—all uncompressed source files. Worse, every request hit the backend, with a cache hit rate of just 12%. After working late into the night, I spent 2 hours adjusting the Nginx configuration: enabling gzip compression, implementing proper caching strategies, and tuning connection pool parameters. The next morning, homepage load time dropped to 1.6 seconds, and backend QPS was nearly cut in half.
Honestly, this kind of problem is all too common. Many people just install Nginx and run it—gzip disabled by default, caching configured haphazardly, connection limits left at default values. The result? As soon as traffic spikes, the server gasps for breath.
This article compiles Nginx performance tuning configurations I’ve validated in production environments. gzip compression can reduce transfer size by 60-80%, and switching to Brotli saves another 15-25%; with a 95% cache hit rate, backend load can drop by 90%; proper connection pool configuration can triple or quadruple concurrency capacity; adding advanced techniques like Thread Pools and reuseport can push single-server RPS to 50K-80K. I’ll lay out the configuration details, pitfalls encountered, and real-world test data for each module.
Chapter 1: gzip/Brotli Compression Configuration — Reducing Transfer Size
Let’s start with why gzip is so important. Imagine your HTML file is 100KB raw, but after gzip compression, it might be only 20-25KB. That 75-80KB of saved bandwidth means faster loading for users and lower traffic costs for you.
I first realized this issue with a client’s project. Over 60% of their users were on mobile devices, many accessing via 4G networks. Homepage load times were 3-4 seconds, with bounce rates hitting 70%. After adding gzip, transfer size dropped by 70%, and above-the-fold load time fell to around 1.5 seconds.
1.1 Basic gzip Configuration: Getting It Running
Nginx’s gzip configuration isn’t actually complex—the core is just a few lines:
http {
gzip on;
gzip_vary on;
gzip_min_length 1000;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}
gzip on is the switch—no explanation needed. gzip_vary on is crucial; it adds Vary: Accept-Encoding to response headers, telling CDNs and browsers that response content varies based on client compression capability, avoiding cache confusion.
Setting gzip_min_length to 1000 bytes means files smaller than 1KB won’t be compressed. Tiny files don’t benefit much from compression and would just waste CPU. gzip_types specifies which MIME types to compress—by default, only text/html is compressed, so you need to add CSS, JS, JSON, XML, and others.
1.2 Advanced gzip Configuration: Compression Level and MIME Type List
Compression level is a balancing act. Nginx’s gzip_comp_level can be set from 1-9; higher numbers mean better compression but more CPU consumption.
I ran a set of tests:
| Compression Level | HTML Compression Ratio | CPU Time (ms) | Recommended Scenario |
|---|---|---|---|
| 1 | 65% | 2 | CPU-constrained |
| 4 | 72% | 3 | Balanced (recommended) |
| 6 | 75% | 5 | Bandwidth-constrained (recommended) |
| 9 | 78% | 12 | Extreme scenarios |
Honestly, levels 4 and 6 are the best choices for most situations. Level 9 doubles CPU consumption but only adds a few percentage points to compression ratio—not worth it.
Here’s the complete gzip configuration I use in production:
# gzip compression configuration
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
application/xml+rss
application/xhtml+xml
application/x-javascript;
gzip_disable "msie6";
The gzip_proxied any parameter is easily overlooked. If your Nginx acts as a reverse proxy and the backend doesn’t return Content-Length in response headers, compression is disabled by default. Setting it to any forces compression of all qualifying responses.
gzip_disable "msie6" is for compatibility with old IE6, which has issues with gzip support. IE6 is basically extinct now, so this line can be removed, but I keep it just in case.
1.3 Which File Types Benefit Most?
Based on my test data, compression effectiveness varies significantly by file type:
| File Type | Original Size | Compressed | Compression Ratio |
|---|---|---|---|
| HTML | 100KB | 20-25KB | 75-80% |
| CSS | 80KB | 24-28KB | 65-70% |
| JavaScript | 120KB | 36-42KB | 65-70% |
| JSON API | 50KB | 20-25KB | 50-60% |
| Images/Video | Already compressed | Ineffective | 0-5% |
Images and videos are already compressed (JPEG, PNG, MP4), so gzipping them actually increases size. Never add image/* and video/* to gzip_types—it’s counterproductive.
I once helped troubleshoot an issue where someone had added image/jpeg to their gzip_types, and image sizes actually increased by 3-5%. I’ve made this rookie mistake too—back when I didn’t understand, I wanted to add every MIME type.
1.4 Brotli: Another 15-25% Beyond gzip
If you want to go further, Brotli is a better choice. This is a compression algorithm developed by Google that achieves 15-25% better compression than gzip at the same compression level. It’s especially suitable for static resources since browser support is now widespread.
However, there’s a catch: Brotli isn’t a default Nginx module and requires additional compilation or dynamic module installation. I use the official dynamic module approach without recompiling Nginx:
# Need to load modules first (if using dynamic modules)
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;
http {
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript application/json;
brotli_min_length 256;
}
Brotli’s compression level is also 1-11, but I recommend setting it to 6. Too high (like 11) significantly increases compression time, which isn’t suitable for dynamically generated content. Static files can be pre-compressed with Brotli at the highest level, and Nginx can serve the pre-compressed .br files directly.
Real-world comparison data:
| Compression Method | 100KB HTML Compressed | Compression Time | Browser Support |
|---|---|---|---|
| gzip (level 6) | 25KB | 5ms | Nearly all |
| Brotli (level 6) | 18KB | 15ms | 95%+ |
| Brotli (pre-compressed level 11) | 15KB | 0 | 95%+ |
My recommendation: use Brotli level 4-6 for dynamic content and pre-compression for static resources. If Nginx compilation is too troublesome, gzip works well enough—75% compression ratio is already impressive.
Chapter 2: Caching Strategy Configuration — Static Content Acceleration
Caching delivers the most direct performance gains. Properly configured, 95% of requests can be returned directly from Nginx without hitting the backend. I’ve seen too many systems where backend servers are sweating while Nginx cache is barely used—not because it wasn’t enabled, but because it was misconfigured.
2.1 proxy_cache or fastcgi_cache?
Nginx offers two caching mechanisms:
- proxy_cache: Caches upstream server responses, suitable for reverse proxy scenarios (e.g., Node.js, Python, Go services)
- fastcgi_cache: Caches FastCGI process responses, suitable for PHP-FPM scenarios
Choose based on your backend stack. For PHP, use fastcgi_cache; for Node.js, Python, Go, use proxy_cache. Both have nearly identical configuration logic, so I’ll use proxy_cache as an example below.
2.2 Complete proxy_cache Configuration
First, define the cache path in the http block:
http {
proxy_cache_path /var/cache/nginx
levels=1:2
keys_zone=my_cache:10m
max_size=10g
inactive=60m
use_temp_path=off;
}
Let me explain each line:
levels=1:2: Cache directory hierarchy, 1:2 means two-level directory structure, avoiding too many files in a single directorykeys_zone=my_cache:10m: Cache zone name and metadata memory size, 10m can store about 80,000 cache keysmax_size=10g: Total cache size limit, LRU eviction when exceededinactive=60m: Cache entries not accessed for 60 minutes will be cleaned upuse_temp_path=off: Write directly to cache directory, avoiding temp file move overhead
Then enable it in server or location:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_cache my_cache;
# Cache validity configuration
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 1m;
# Cache key design
proxy_cache_key $scheme$request_method$host$request_uri;
# Degradation strategy
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
# Add cache status to response headers (for debugging)
add_header X-Cache-Status $upstream_cache_status;
}
}
proxy_cache_valid is the core configuration, defining cache duration for different status codes:
200 302 10m: Normal responses cached for 10 minutes404 1m: 404 errors cached for 1 minute, preventing malicious requests from pounding the backendany 1m: Other status codes cached for 1 minute
2.3 Cache Key Design and Invalidation Strategy
The cache key proxy_cache_key determines which requests are considered “identical.” Default is $scheme$proxy_host$request_uri, but I recommend explicitly declaring:
proxy_cache_key $scheme$request_method$host$request_uri;
This makes the cache key include protocol, request method, hostname, and full URI. This configuration is more precise if your site supports both GET and POST, or has multiple domains.
Cache invalidation is a headache. Common strategies:
- Time-based expiration:
proxy_cache_validsets duration, expires automatically - Active bypass: Use
proxy_cache_bypassto skip cache - Cache purging: Commercial Nginx Plus has
proxy_cache_purge
I generally use the second approach, controlling via request headers:
# Bypass cache with specific header
proxy_cache_bypass $http_x_nocache;
# Or bypass via specific parameter
proxy_cache_bypass $arg_nocache;
When you need to refresh the cache, just add ?nocache=1 or header X-Nocache: 1.
2.4 Degradation Strategy: Serving Even When Backend Is Down
proxy_cache_use_stale is a practical configuration. When the backend service errors or times out, Nginx can return stale cached content instead of failing directly.
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
During last year’s Double 11 shopping festival, our backend service had issues during scaling, with intermittent 502 errors from the API service. Thanks to this degradation configuration in the cache, users were barely affected—though the cache was a few minutes stale, content still loaded normally. Once the backend recovered, the cache automatically updated.
Real-world data comparison:
| Metric | No Cache | Cache Hit | Degradation Mode |
|---|---|---|---|
| Response Time | 150-200ms | 5-10ms | 5-10ms |
| Backend QPS | 1000 | 50 | 0 |
| User Experience | Normal | Normal | Slightly stale |
When cache hits, response time drops from 200ms to 5-10ms—nearly 20x faster. The benefit is immediately obvious.
2.5 Microcaching Pattern: Accelerating Dynamic Content Too
Many people think dynamic content can’t be cached, but microcaching works well. Cache dynamic pages for 1-5 seconds to significantly reduce backend load during high-concurrency bursts.
I tried this approach on an e-commerce homepage. The homepage had real-time recommendations and inventory display—completely dynamic. But during promotions, traffic spiked and the backend couldn’t handle it. I added 5-second microcaching:
proxy_cache_path /var/cache/nginx/micro levels=1:2 keys_zone=micro:10m max_size=1g;
location / {
proxy_cache micro;
proxy_cache_valid 200 5s; # Only cache 5 seconds
proxy_cache_lock on; # Prevent cache stampede
proxy_cache_background_update on; # Background async cache update
}
proxy_cache_lock on is crucial. When cache expires, the first request fetches new data from backend, while others wait and use stale cache. This prevents “cache stampede” where many requests hit the backend simultaneously when cache expires.
The effect was remarkable. Homepage TTFB dropped from 800ms to around 5ms, and backend QPS fell from 2000 to 400. Users barely notice 5 seconds of cache delay, but server load dropped significantly.
2.6 Conditional Requests: Bandwidth-Saving Technique
proxy_cache_revalidate lets Nginx use conditional requests (If-Modified-Since / If-None-Match) to verify with the backend whether cache has expired. If the backend returns 304 Not Modified, there’s no need to retransmit full content—just update cache metadata.
proxy_cache_revalidate on;
This configuration is especially useful for bandwidth-sensitive scenarios. For example, if your backend returns large files that don’t change frequently, conditional requests save substantial data transfer.
Chapter 3: Connection Pool Configuration — Essential for High Concurrency
gzip and caching solve “how to transmit faster,” while connection pools solve “how to handle more requests.” With default configuration, a single Nginx worker has a maximum of only 1024 concurrent connections. When traffic arrives, it’s simply not enough.
3.1 worker_connections: Calculate the Limit
Maximum concurrent connections formula:
Max Concurrency = worker_processes × worker_connections
Assuming your server has 8 CPU cores, worker_processes set to 8 (or auto for automatic matching), and worker_connections set to 4096:
Max Concurrency = 8 × 4096 = 32768
This number looks large, but remember: each request typically occupies two connections (client to Nginx, Nginx to backend). So actual concurrent requests are roughly half this number.
Configuration in the events block:
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
use epoll is the default on Linux, doesn’t need to be explicitly written, but adding it makes it clearer. multi_accept on lets workers accept multiple new connections simultaneously, reducing connection queuing in high-concurrency scenarios.
3.2 Client keepalive: Connection Reuse to Reduce Overhead
TCP connection establishment requires a three-way handshake with significant overhead. keepalive allows connections between clients and Nginx to be reused, avoiding rebuilding connections for every request.
http {
keepalive_timeout 65;
keepalive_requests 1000;
}
keepalive_timeout 65: Connections stay alive for 65 seconds, then close. This value shouldn’t be too large or it will occupy excessive server resources; shouldn’t be too small either or reuse effect is minimal. 60-75 seconds is a reasonable range.
keepalive_requests 1000: Maximum 1000 requests per connection. Setting too low causes frequent disconnections; setting too high may cause resource leaks. 1000 is a tested stable value.
There’s also a newer parameter keepalive_time that controls total lifetime of a single connection (regardless of request count). If your server runs for long periods and accumulated connections might cause resource issues, add:
keepalive_time 1h; # Single connection lives max 1 hour
3.3 upstream keepalive: Backend Connection Pool
This configuration is often overlooked but has significant impact. Connections between Nginx and backend services can also be reused, reducing TCP establishment overhead.
upstream backend {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
keepalive 64;
keepalive_timeout 60s;
keepalive_requests 1000;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
keepalive 64: Maintain a pool of 64 idle connections. Adjust this value based on your number of backend servers—generally set to 4-8x the number of backend servers.
proxy_http_version 1.1 and proxy_set_header Connection "" must both be added. HTTP/1.1 supports keepalive by default, and clearing the Connection header is required for connection reuse. Without these two lines, upstream keepalive won’t work.
Real-world comparison:
| Configuration | Connections Established/min | CPU Overhead | Recommended Scenario |
|---|---|---|---|
| No upstream keepalive | 6000 | High | Low traffic |
| keepalive 32 | 3000 | Medium | Medium traffic |
| keepalive 64 | 1500 | Low | High traffic |
After adding upstream keepalive, connection establishment overhead dropped by 50%. This configuration is especially important in high-concurrency scenarios.
3.4 File Descriptor Limits: Don’t Forget the System Layer
Nginx’s connection count is limited by the system’s file descriptor limit. If your worker_connections is set to 4096 but the system only allows 1024 files per process, it’s still not enough.
Check current limits:
ulimit -n
If the returned value is less than 65536, you need to increase it in system configuration. Edit /etc/security/limits.conf:
* soft nofile 65536
* hard nofile 65536
Then declare it in Nginx configuration too:
worker_rlimit_nofile 65536;
This configuration goes in the main block (same level as worker_processes), letting Nginx request sufficient file descriptors at startup.
3.5 Quick Reference for Recommended Parameters
I’ve compiled recommended configurations for different scenarios:
| Parameter | Low Traffic (<1000 QPS) | Medium Traffic (1000-5000 QPS) | High Traffic (>5000 QPS) |
|---|---|---|---|
| worker_processes | auto | auto | auto |
| worker_connections | 1024 | 2048 | 4096 |
| keepalive_timeout | 60 | 65 | 75 |
| keepalive_requests | 100 | 500 | 1000 |
| upstream keepalive | 16 | 32 | 64 |
| worker_rlimit_nofile | 4096 | 8192 | 65536 |
These are starting points; actual tuning requires load testing data. I typically use wrk or ab for load testing, observing connection count and response time curves to find optimal values.
Chapter 4: Advanced Optimization — Thread Pools and reuseport
The first three chapters cover optimization for most scenarios. If your traffic is particularly high (single-server RPS target over 50K) or you’re especially sensitive to latency, here are two advanced configurations to try.
4.1 Thread Pools: Breaking Through sendfile Bottlenecks
Nginx defaults to a single-threaded event-driven model, efficient enough for most scenarios. But there’s a hidden bottleneck in high-concurrency static file serving: while sendfile is zero-copy, both file reading and socket writing happen in the same worker thread. When disk IO is slow, it blocks the entire worker.
Thread Pools solve this problem. They offload file reading and sending to independent thread pools, with workers only handling scheduling—not blocking on IO.
Nginx’s official blog has a test case showing 9x performance improvement. The test scenario was downloading 1MB files, originally limited by disk IO, then breaking through the bottleneck after adding Thread Pools.
Configuration method:
http {
thread_pool default threads=32 max_queue=65536;
aio threads=default;
sendfile_max_chunk 512k;
}
threads=32 means 32 threads in the pool, max_queue=65536 is the maximum queued tasks. aio threads=default enables async IO and specifies which thread pool to use. sendfile_max_chunk 512k controls chunk size for each send, preventing large files from occupying too long.
However, Thread Pools aren’t a magic bullet. If your backend is primarily dynamic content (API services), disk IO isn’t the bottleneck, and adding this might increase context switching overhead. My recommendation: only consider enabling for static file serving and large file download scenarios.
4.2 Socket Sharding (reuseport): Reducing Connection Latency
Nginx 1.9.1 introduced the reuseport parameter, allowing multiple workers to independently listen on the same port, avoiding lock contention between workers.
In traditional mode, all workers share a single listening socket, and new connections must contend for the accept mutex. This causes significant lock contention overhead and latency jitter in high-concurrency scenarios.
With reuseport:
server {
listen 80 reuseport;
}
Each worker has its own listening socket, and the kernel automatically distributes new connections to different workers. Lock contention is completely eliminated.
Real-world data (from Nginx official blog):
| Metric | Without reuseport | With reuseport |
|---|---|---|
| Average Latency | 15.65ms | 12.35ms |
| Latency Std Dev | 3.5ms | 1.2ms |
| Connection Distribution | Uneven | Even |
Latency dropped 21%, and more importantly, latency jitter decreased significantly. This configuration is most effective in high-concurrency scenarios (QPS over 20K); low-traffic scenarios may not notice much difference.
4.3 open_file_cache: File Descriptor Caching
For static file serving scenarios, you can use open_file_cache to cache file descriptors and metadata, avoiding disk lookups for every request:
http {
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
max=10000: Cache up to 10,000 file info entriesinactive=30s: Clean up if not accessed for 30 secondsopen_file_cache_valid 60s: Validate cache expiry every 60 secondsopen_file_cache_errors on: Also cache error states like file not found
This configuration is effective for static sites. Not recommended for dynamic content scenarios where files change frequently—caching would cause content not to update.
Chapter 5: Comprehensive Configuration Template — Production Ready
The previous four chapters covered principles; here’s an integrated configuration template. You can adjust parameters based on actual scenarios, but the framework is universal.
# nginx.conf production template (high-traffic scenario)
user nginx;
worker_processes auto;
worker_rlimit_nofile 65536;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# gzip compression configuration
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml
application/xml+rss application/xhtml+xml;
gzip_disable "msie6";
# Cache path configuration
proxy_cache_path /var/cache/nginx
levels=1:2
keys_zone=my_cache:10m
max_size=10g
inactive=60m
use_temp_path=off;
# Client connection configuration
keepalive_timeout 65;
keepalive_requests 1000;
keepalive_time 1h;
# File cache (optional for static sites)
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
# Backend server group
upstream backend {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
keepalive 64;
keepalive_timeout 60s;
keepalive_requests 1000;
}
server {
listen 80 reuseport;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Cache configuration
proxy_cache my_cache;
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_key $scheme$request_method$host$request_uri;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
proxy_cache_revalidate on;
# Debug response header
add_header X-Cache-Status $upstream_cache_status;
}
}
}
Differentiated Configuration for Three Scenarios
E-commerce websites: Homepage and product detail pages change frequently, so cache duration should be shorter—10-15 minutes is sufficient. upstream keepalive should be larger because database queries are frequent and backend pressure is high. Microcaching can be enabled during promotions, caching for 1-3 seconds.
API services: High real-time data requirements, proxy_cache_valid might only be 1-5 minutes. gzip works well for JSON—definitely add it. Install Brotli if possible; API responses are small but frequent, so compression benefits are significant.
Static sites: HTML, CSS, JS barely change—cache can be set to 1 hour or longer. gzip compression benefits most here since static files are mostly text. open_file_cache and Thread Pools can be enabled; file descriptor caching and async IO help significantly for static content.
Performance Improvement Comparison (Updated)
| Configuration Item | Before Optimization | After Optimization | Improvement |
|---|---|---|---|
| HTML Transfer Size (gzip) | 100KB | 25KB | 75% ↓ |
| HTML Transfer Size (Brotli) | 100KB | 18KB | 82% ↓ |
| API Response Time (cache hit) | 200ms | 8ms | 96% ↓ |
| Concurrent Connection Capacity | 1000 | 4000 | 4x ↑ |
| RPS (single server optimized) | 10K | 50K-80K | 5-8x ↑ |
| TTFB (reuseport) | 15.65ms | 12.35ms | 21% ↓ |
These data points are from my testing and official documentation combined. Actual results depend on your server configuration, network environment, and business characteristics—load testing validation is important.
Chapter 6: Common Issues and Troubleshooting
Here are several high-frequency issues I’ve encountered, with direct solutions.
Q: gzip compression not working, no Content-Encoding: gzip in response headers
Check three places:
- Is
gzip onin the correct configuration level (http block)? - Does
gzip_typesinclude your response MIME type? - Is response size larger than
gzip_min_length?
Test with curl: curl -H "Accept-Encoding: gzip" -I http://your-site.com
Q: Low cache hit rate, X-Cache-Status mostly MISS
Common causes:
- Poorly designed cache key, every request considered “different”
proxy_cache_validset too short, cache expires before use- Backend returns
Cache-Control: no-cacheorSet-Cookiein response headers
Check response headers to confirm no cache-blocking directives.
Q: worker_connections insufficient, getting 502 errors
Check Nginx error log—if you see worker_connections are not enough, concurrency exceeded the limit.
Solutions:
- Increase
worker_connectionsvalue - Check for connection leaks (unreasonable keepalive settings)
- Consider adding servers for load balancing
Q: Do gzip and sendfile conflict?
No conflict. gzip compresses response content; sendfile handles file transfer method. Both can be enabled simultaneously. The only note: gzip for dynamic content requires in-memory compression, not using sendfile; static pre-compressed files can use sendfile directly.
Q: upstream keepalive not working?
Two common reasons:
- Missing
proxy_http_version 1.1—HTTP/1.0 doesn’t support keepalive by default - Missing
proxy_set_header Connection ""—without clearing this header, connection will be closed
Both lines must be added in the location block, not the upstream block.
Q: reuseport error “duplicate listen options”?
reuseport can only be declared once in the listen line, not repeated elsewhere. Ensure each server block’s listen line has reuseport at most once. If multiple servers listen on the same port, each needs independently added reuseport.
Q: High memory usage, frequent server OOM
Possible causes:
keys_zoneinproxy_cache_pathset too large- Too many cached files, high memory mapping usage
keepaliveconnection pool too large, idle connections consuming resources
Appropriately reduce these parameters, or add more memory to the server.
Final Thoughts
Having covered all this, the core of Nginx performance tuning is really three things: compression, caching, and connection pools. Add a few advanced techniques (Brotli, microcaching, Thread Pools, reuseport), and you can push single-server RPS from 10K to 50K-80K.
Tuning isn’t a one-time task. I recommend this progression:
- Enable gzip first: Smallest change, most direct benefit, done in ten minutes
- Configure caching next: Design caching strategy based on business scenario, completable within a day
- Then tune connection pools: Requires load testing validation, suitable after traffic stabilizes
- Finally try advanced optimizations: Thread Pools and reuseport, only consider for very high traffic
After each change, remember to load test and validate results. wrk or ab both work—observe response time, QPS, error rate changes. Don’t tune by feel—use data.
Final checklist you can follow:
- gzip enabled, MIME types configured completely
- gzip_comp_level set to 4-6, balancing CPU and compression ratio
- Brotli configured (if available)
- proxy_cache_path configured, cache size reasonable
- proxy_cache_valid set according to business scenario
- Microcaching enabled (high-concurrency dynamic content scenarios)
- proxy_cache_use_stale degradation strategy configured
- worker_connections set to 4096 or higher
- keepalive_timeout set to 60-75 seconds
- upstream keepalive configured (including HTTP/1.1 and Connection header)
- reuseport enabled (high-traffic scenarios)
- File descriptor limit increased (65536)
- Response headers include X-Cache-Status for debugging
That’s about it. Questions? Leave a comment and I’ll respond when I can.
FAQ
gzip compression not working, no Content-Encoding: gzip in response headers?
Low cache hit rate, X-Cache-Status mostly MISS?
worker_connections insufficient, getting 502 errors?
Common reasons upstream keepalive not working?
How to choose between Brotli and gzip?
Recommended configuration parameters for different traffic scenarios?
16 min read · Published on: May 15, 2026 · Modified on: May 15, 2026
Nginx Practice Guide
If you landed here from search, the fastest way to build context is to jump to the previous or next post in this same series.
Previous
Nginx Dynamic Upstream: Real-Time Service Discovery with Lua
Deep dive into OpenResty's three-layer dynamic upstream architecture, compare health check solutions, and provide complete integration code for Consul/Nacos/etcd to achieve real-time service discovery in containerized environments.
Part 5 of 6
Next
This is the latest post in the series so far.
Related Posts
Nginx Reverse Proxy Complete Guide: Upstream, Buffering, and Timeout
Nginx Reverse Proxy Complete Guide: Upstream, Buffering, and Timeout
Nginx Performance Tuning: gzip, Caching, and Connection Pool Configuration
Nginx Performance Tuning: gzip, Caching, and Connection Pool Configuration
Nginx SSL/TLS Configuration in Practice: From HTTPS Certificates to A+ Security Hardening
Comments
Sign in with GitHub to leave a comment