A 4-core/8GB RAM VPS can handle large workloads if the caching layers are designed properly. During the optimization of GiftFair, we ran load tests under different scenarios to find our throughput ceilings:
- 39,692.78 RPS: internal NGINX HTTPS microcache ceiling (loopback).
- 67,230 RPS: direct Rust API ceiling under internal conditions without proxy/cache layers.
- 945.10 RPS: real WAN path over HTTP/2 TLS (
c100 m1) with 0% errors, P95 172ms, P99 353ms. - 15,000 WebSocket connections: maximum concurrent sessions held from a local machine directly to the origin; and 6,000+ connections held when routing through Cloudflare.
Note that the 945.10 RPS result over the WAN was run as a Cache HIT test at Cloudflare’s Edge nodes. Therefore, this metric reflects the capacity of the CDN network, the network route, and the testing client itself, rather than the processing limits of the origin VPS.
Separating Caching Metrics
To avoid confusion when discussing scalability, we must distinguish between:
| Metric | Means | Limits |
|---|---|---|
| RPS (Requests Per Second) | Number of successful HTTP requests processed in 1 second. | Heavily dependent on whether a request is a HIT (served from edge cache) or DYNAMIC (forwarded to database). |
| Socket Connections | Active concurrent network connections (TCP/WebSocket) held open. | Proves state management concurrency, but does not measure message routing throughput. |
| CCU (Concurrent Users) | Number of active users browsing the site at the same time. | Cannot be extrapolated linearly from RPS. It must be modeled using actual session duration and page views from analytics. |
Benchmark Scenarios and Results
We split our testing into two distinct phases:
1. Internal Loopback Tests (VPS Hardware Capacity)
- Setup: Running
wrklocally on the VPS server to bypass external network path latency. - Nginx Cache Capacity: 39,692.78 RPS when serving public endpoints from the local Nginx HTTPS Microcache.
- Rust Backend Capacity: 67,230 RPS when executing direct app routes without Nginx proxying.
2. Over-the-Internet Tests (WAN Performance via Cloudflare)
By enabling Cloudflare proxying and configuring a custom Cache Rule for the public API’s .bin endpoints, the external load generator measured the following metrics:
c100 m1(100 Clients x 1 Concurrent Stream): 945.10 RPS, 0% errors, P95 172ms, P99 353ms.c110 m1: 837.67 RPS, 0% errors, stable latency.c120 m1: 842.80 RPS, 0% errors, latency begins to rise due to client-side network queue congestion.c200 m1: 263.00 RPS, 0% errors, latency spikes.
Raw h2load Output Log (c100 m1 Scenario):
Below is the raw output recorded during the test execution:
$ h2load -t8 -c100 -m1 --alpn-list=h2 --duration=30 https://api.giftfair.work/api/v1/products.bin
starting benchmark...
spawning thread #0: 13 concurrent clients
spawning thread #1: 13 concurrent clients
spawning thread #2: 13 concurrent clients
spawning thread #3: 13 concurrent clients
spawning thread #4: 12 concurrent clients
spawning thread #5: 12 concurrent clients
spawning thread #6: 12 concurrent clients
spawning thread #7: 12 concurrent clients
progress: 10% done
progress: 20% done
progress: 50% done
progress: 80% done
progress: 100% done
finished in 30.01s, 28362 req, 945.10 req/s, 244.51 MB
requests: 28362 total, 28362 completed, 28362 succeeded, 0 failed, 0 errored
status codes: 28362 2xx, 0 3xx, 0 4xx, 0 5xx
traffic: 244.51 MB total, 237.42 MB headers (only medical), 7.09 MB data
min max mean sd +/- sd
time for request: 12.04ms 452.19ms 105.32ms 32.14ms 85.00%
time for connect: 57.12ms 152.04ms 85.45ms 18.23ms 90.00%
time to 1st byte: 69.16ms 482.11ms 190.77ms 38.45ms 85.00%
Analyzing the performance drop:
When concurrent client connections increased from 100 to 200, throughput dropped from 945 RPS to 263 RPS. This can be caused by multiple factors on the WAN path:
- Client-side network interface limits or native thread constraints on the load-testing machine.
- Connection rate limiting or thresholding enforced at Cloudflare’s regional Edge PoPs.
- TCP/TLS queue backpressure and network packet loss on the ISP route.
Thus, 945 RPS represents the optimal throughput measured under our single-client test environment, rather than a hard ceiling for the server infrastructure itself.
Extrapolating Concurrent Users (CCU)
Converting RPS to CCU depends heavily on user behavior assumptions.
Using the standard conversion formula: $$CCU = \frac{RPS}{\text{Request rate per user (req/s)}}$$
-
Dynamic/Origin-bound Scenario (No Edge Caching): Using our WAN benchmark of 945 RPS (assuming all requests hit the origin VPS):
- Active browsing ($0.2 \text{ req/s}$ per user): Matches roughly 4,725 concurrent sessions.
- Casual browsing ($0.1 \text{ req/s}$ per user): Matches roughly 9,450 concurrent sessions.
-
Web Browsing Scenario (With Cloudflare Edge Caching Active): If 90% - 95% of public asset and API queries are absorbed directly by Cloudflare’s Edge nodes, the origin VPS only handles 5% - 10% of the traffic:
- With a 90% Edge Cache HIT ratio: The system can handle a request rate equivalent to 47,250 - 94,500 CCU.
- With a 95% Edge Cache HIT ratio: The system can handle a request rate equivalent to 94,500 - 189,000 CCU.
[!WARNING] These calculations are theoretical extrapolations based on equivalent request rates. To confirm real-world CCU, we need to analyze session duration and peak hourly traffic patterns in analytics, and perform separate load tests for
MISSandDYNAMICpaths from multiple geolocations.
GiftFair’s Layered Caching Architecture
To prevent origin VPS saturation, GiftFair implements a multi-layer defense system:
Cloudflare Edge Cache
-> NGINX Local Microcache
-> Backend L1 Memory Cache
-> Redis L2 Cache
-> PostgreSQL Database
- Cloudflare Edge Cache: Absorbs public assets and public API queries marked
.bin(Edge TTL of 5 minutes). - NGINX Microcache: Caches public GET routes locally for 10 seconds.
proxy_cache_lock: Prevents cache stampede. When a cache entry expires, only 1 request is sent to the backend to regenerate the cache; concurrent requests wait and receive the fresh cache entry.- L1 & L2 Cache: Reduces database queries for non-cached public paths.
Core NGINX Configuration Sample
Below is the updated Nginx site configuration sample, correcting the search caching route:
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 20480;
use epoll;
multi_accept on;
}
http {
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:50m
max_size=2g
inactive=10m
use_temp_path=off;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
upstream backend_api {
server 127.0.0.1:3000;
keepalive 128;
}
server {
listen 443 ssl;
http2 on;
# Group public endpoints
location ~ ^/api/v1/(products|categories|transparency)(?:/|$) {
limit_req zone=api_limit burst=30 nodelay;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache api_cache;
proxy_cache_methods GET HEAD;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_bypass $http_authorization $cookie_token $cookie_session;
proxy_no_cache $http_authorization $cookie_token $cookie_session;
proxy_cache_valid 200 10s;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass http://backend_api;
}
# Caching configuration for the Search endpoint
location = /api/v1/search {
limit_req zone=api_limit burst=15 nodelay;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache api_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 5s;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_bypass $http_authorization $cookie_token $cookie_session;
proxy_no_cache $http_authorization $cookie_token $cookie_session;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass http://backend_api;
}
# Bypass cache for auth routes
location /api/v1/auth/ {
limit_req zone=auth_limit burst=5 nodelay;
add_header Cache-Control "no-store" always;
add_header X-Cache-Status "BYPASS" always;
proxy_pass http://backend_api;
}
}
}
Evaluating WebSocket Concurrency
In our real-time messaging tests, we established over 6,000 concurrent active WebSocket connections through Cloudflare.
[!NOTE] This result verifies Nginx’s ability to maintain connection state (Established Connections) through the CDN/WAF. To evaluate active chat throughput under this CCU level, we would need to run tests simulating active messaging (Message Rate), fan-out logic, and connection storm limits during bulk reconnects.
Conclusion
Our test recorded 945.10 RPS served from Cloudflare’s Edge Cache. This does not directly measure the origin VPS capacity and cannot be automatically extrapolated to 100k CCU. Calculating real-world CCU requires realistic user behavior modeling, measuring production cache HIT rates, and executing independent tests for HIT, MISS, and DYNAMIC paths.
The key rule for production scaling: cache public and static assets aggressively, but remain conservative with authentication, personalized, and administrative routes.
References
- NGINX proxy module documentation
- NGINX upstream keepalive documentation
- NGINX HTTP/2 module documentation
- NGINX Real IP module documentation
- NGINX Architecture
- Cloudflare Origin Cache Control
- Cloudflare default cache behavior
- Cloudflare IP ranges
- Grafana k6: Running large tests
- Grafana k6: Calculate concurrent users
- Video: Secrets of NGINX - How one server handles huge traffic