Caching #

In the world of web performance optimization, there’s one golden rule that can’t be disputed: the fastest operation is the one that never happens. Caching is the real embodiment of this golden rule. Instead of letting our backend server process the same database queries, render the same HTML pages, or read the same static files repeatedly for every visitor request, we can store those processing results in a fast-access location and serve them instantly.

Nginx provides a very powerful and flexible caching system, divided into two main complementary categories: Client-Side Browser Caching (directing user browsers to store assets locally) and Server-Side Proxy Caching (storing responses from upstream backend servers in Nginx’s storage). In this article, we’ll discuss implementation strategies for both caching types in depth, dissect the proxy_cache_path configuration, design cache bypass rules for sensitive dynamic data, utilize the stale cache feature when the backend is down, and prevent the thundering herd problem on production servers.

Caching Strategy: Two Defense Layers #

To optimize performance to the maximum, we must apply caching at two different defense layers:

  1. First Layer: Browser Caching (Client): The response is stored directly in the user’s computer memory. The browser doesn’t need to send any network request at all when the user navigates pages, so loading feels instant (0 milliseconds latency).
  2. Second Layer: Proxy Caching (Server): If the client browser is forced to send a request to our server (e.g., because the browser cache is empty or the user pressed reload), Nginx checks its local cache storage first. If the data is available, Nginx immediately returns the response without burdening our backend application server.

Client-Side Browser Caching #

Browser caching is fully controlled by sending certain HTTP response headers from Nginx to the client browser. The main headers we use are Cache-Control and Expires.

Here’s the Nginx configuration to instruct browsers to efficiently store our static assets:

server {
    listen 80;
    server_name example.com;
    root /var/www/html;

    # 1. Static Assets That Never Change (Styles & Program Code)
    # These files are generated with unique content hashes (e.g., main.a7f8b9.js) by the build tool
    location ~* \.(css|js|woff2|woff|ttf|otf|eot)$ {
        expires 1y; # Valid for 1 year
        add_header Cache-Control "public, no-transform, immutable";
        access_log off;
    }

    # 2. Media Files & Images (Rarely Change)
    location ~* \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
        expires 30d; # Valid for 30 days
        add_header Cache-Control "public, no-transform";
        access_log off;
    }

    # 3. Dynamic HTML Pages & Manifest Files (Must Always Be Fresh)
    location ~* \.(html|htm|json)$ {
        expires -1; # Expires instantly
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

Dissecting Cache-Control Directives #

  • public: Declares that the response may be stored by the client browser as well as by intermediate public proxies (like CDNs or ISP caching).
  • no-transform: Forbids intermediate proxies from compressing or changing our asset images/formats unilaterally to save their bandwidth.
  • immutable: Tells the browser that this file will never change during its validity period. Modern browsers won’t send If-None-Match validation requests (304 Not Modified) to our server at all, even if the user presses F5/Refresh. This saves SSL/TCP connection processing at Nginx.
  • no-store: The browser is strictly forbidden from storing this file in local cache (crucial for admin pages or bank transactions).
  • no-cache: The browser may store the file, but must send a validation request to the server on every visit to make sure the file hasn’t changed on the server.

Server-Side Proxy Caching (proxy_cache) #

When a request is forced to come into our server, we want to minimize the load on the backend application side. Nginx can cache responses from HTTP backends in our server’s local disk storage.

Here’s the cache decision flow diagram in Nginx:

flowchart TD
    Request["Client Sends a Request"] --> CheckBypass{"Does the Request Meet<br/>Cache Bypass Conditions?"}
    CheckBypass -->|Yes: Login/Cookie/Non-GET| PassBackend["Request Data Directly from the Backend"]
    CheckBypass -->|No| CheckCache{"Is the Data in the Disk Cache?"}

    CheckCache -->|Yes: Cache HIT| ReturnClient["Return the Response from Cache"]
    CheckCache -->|No: Cache MISS| FetchBackend["Request Data from the Backend"]
    
    FetchBackend --> SaveCache["Save the Response to the Disk Cache"]
    SaveCache --> ReturnClient2["Return the Response to the Client"]
    PassBackend --> ReturnClient2

    classDef default fill:#f9f9f9,stroke:#d1d5db,stroke-width:1px,color:#111827;
    classDef hitStyle fill:#d1fae5,stroke:#10b981,stroke-width:2px,color:#065f46;
    classDef missStyle fill:#fee2e2,stroke:#ef4444,stroke-width:2px,color:#991b1b;
    classDef processStyle fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a;
    
    class ReturnClient hitStyle;
    class CheckCache,CheckBypass processStyle;
    class FetchBackend,PassBackend missStyle;

1. Defining the Caching Zone (proxy_cache_path) #

Just like the custom log format directive, the cache storage zone configuration must be declared at the global http context level. We determine the local storage folder on the server disk as well as the RAM memory allocation to index those cache keys.

http {
    # Proxy Cache Zone Configuration
    proxy_cache_path /var/cache/nginx/app_cache
        levels=1:2
        keys_zone=my_cache_zone:10m
        max_size=2g
        inactive=60m
        use_temp_path=off;
}

Let’s break down the parameters above:

  • /var/cache/nginx/app_cache: The physical directory on our server disk where cache files are stored. Make sure the Nginx user (www-data) has write permission to this folder.
  • levels=1:2: Determines the cache storage subdirectory structure. The value 1:2 means Nginx creates nested folders (e.g., /app_cache/c/29/filename). This is very important for Linux filesystem performance, because putting hundreds of thousands of files in a single directory will extremely slow down disk I/O operations.
  • keys_zone=my_cache_zone:10m: Creates a shared memory zone named my_cache_zone of 10 Megabytes. This memory zone is used by Nginx to store the cache key metadata index list (cache keys). 10MB of memory is enough to hold about 80,000 index keys.
  • max_size=2g: The maximum cache storage size limit on disk (2 Gigabytes). If the cache size exceeds this limit, Nginx’s internal cache manager process automatically deletes the least-frequently-accessed cache files using the LRU (Least Recently Used) algorithm.
  • inactive=60m: Determines the data inactivity expiration limit. If a cache file isn’t accessed at all for 60 minutes by clients, it’s immediately deleted from disk, regardless of its official validity period.
  • use_temp_path=off: Forces Nginx to write cache files directly to the final destination directory without writing to a temp folder first. This saves wasted disk I/O write cycles.

2. Enabling Cache on the Server Block #

After being declared at the http level, we can enable caching on our virtual host server block:

server {
    listen 80;
    server_name api.unisbadri.com;

    location /api/v1/public/ {
        # Enable the cache zone we created
        proxy_cache my_cache_zone;

        # Determine the cache key format (used to match requests)
        proxy_cache_key "$request_method$scheme$host$request_uri";

        # Set cache storage validity based on backend response status codes
        proxy_cache_valid 200 301 302 10m; # Status 200, 301, 302 stored for 10 minutes
        proxy_cache_valid 404 1m;         # Status 404 stored for 1 minute

        # Add a debug HTTP header to the client to monitor the cache status
        add_header X-Cache-Status $upstream_cache_status always;

        proxy_pass http://backend_upstream;
    }
}

Cache Bypass: When to Ignore the Cache? #

Aggressive cache application can trigger personal data leaks if we misconfigure it. We’re strictly forbidden from serving cache for requests containing sensitive dynamic data (like user shopping carts, bank account profile pages, or requests requiring JWT authentication).

Nginx provides two directives to dynamically control these exceptions:

  • proxy_cache_bypass: Determines conditions where Nginx won’t read from cache and will forward the request directly to the backend. However, the backend response result may still be stored to cache for other requests.
  • proxy_no_cache: Determines conditions where the backend response must not be written into the disk cache storage.

Both directives accept variables. If the variable value isn’t empty ("") and isn’t 0, then the exception rule is active.

Production API Caching Security Configuration #

Here’s an example industry-standard configuration to separate cacheable traffic from protected dynamic traffic:

location /api/ {
    proxy_cache my_cache_zone;
    proxy_cache_key "$request_method$scheme$host$request_uri";
    proxy_cache_valid 200 5m;

    # 1. BYPASS the cache if the client sends a Session Cookie or Authorization Token
    proxy_cache_bypass $http_authorization $cookie_session_id;
    proxy_no_cache     $http_authorization $cookie_session_id;

    # 2. BYPASS the cache for all requests other than GET and HEAD (POST, PUT, DELETE, etc.)
    # (We must not cache requests that change database data)
    set $bypass_caching 0;
    if ($request_method != GET) {
        set $bypass_caching 1;
    }
    if ($request_method != HEAD) {
        set $bypass_caching 1;
    }
    
    proxy_cache_bypass $bypass_caching;
    proxy_no_cache     $bypass_caching;

    add_header X-Cache-Status $upstream_cache_status always;
    proxy_pass http://backend_upstream;
}

Stale Caching for High Availability (proxy_cache_use_stale) #

One of Nginx’s most revolutionary features for improving system reliability is its ability to serve expired cache files (stale cache) when our backend application server is having problems.

Suppose we set a cache validity of 5 minutes. At minute 6, our Node.js backend application completely crashes. By default, Nginx returns a 502 Bad Gateway error to all visitors. However, with stale caching enabled, Nginx detects the problem and returns the cache version it has, so users still get the web page data normally.

location / {
    proxy_cache my_cache_zone;
    proxy_cache_valid 200 5m;

    # Serve expired cache if the backend errors, times out, or is being updated
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

    # Contact the backend again to validate the cache if the browser forces a bypass
    proxy_cache_revalidate on;

    proxy_pass http://backend_upstream;
}

Preventing Request Floods (Thundering Herd / Cache Stampede) #

When a very popular cache file expires (e.g., a news site homepage with 10,000 active visitors), by default Nginx forwards all 10,000 simultaneously incoming requests directly to the backend server. This sudden traffic surge is known as the Thundering Herd or Cache Stampede, which often overwhelms the backend database and immediately crashes it.

Nginx solves this problem by providing the proxy_cache_lock directive.

location / {
    proxy_cache my_cache_zone;
    proxy_cache_valid 200 5m;

    # Enable cache locking
    proxy_cache_lock on;

    # Maximum waiting time limit for other requests in the Nginx queue
    proxy_cache_lock_timeout 5s;

    proxy_pass http://backend_upstream;
}

How Does proxy_cache_lock Work? #

When the cache expires and 10,000 requests arrive simultaneously:

  1. Nginx locks the queue, takes only the first request, and forwards it to the backend to fetch new data and update the cache.
  2. Meanwhile, the other 9,999 requests are held in Nginx’s memory queue.
  3. After the first request finishes and the new cache is written to disk, Nginx immediately releases the lock and serves the new cache data to the 9,999 queued requests.
  4. This guarantees our backend receives exactly one update request, keeping our server stable.

Monitoring and Analyzing Cache Status #

We can monitor the effectiveness of our cache configuration by analyzing the $upstream_cache_status variable value we insert into the response header:

  • HIT: The response was successfully fetched directly from Nginx’s disk cache without touching the backend at all (best performance).
  • MISS: The data wasn’t found in the cache (because it’s a new request or the cache was deleted). The request is forwarded to the backend and the result is stored in the cache.
  • EXPIRED: The data was found in the cache, but its validity period has expired. The request is forwarded to the backend to fetch the latest data and update the cache.
  • BYPASS: The request bypassed the cache because it met the proxy_cache_bypass directive conditions (e.g., because the user is logged in).
  • STALE: The backend experienced an error/timeout, and Nginx returned the expired cache data it has to keep the site active.
  • UPDATING: The cache is being updated by another request holding the proxy_cache_lock key. The current client is served stale cache temporarily.

Hit Rate Analysis from the Terminal #

We can calculate our site’s cache effectiveness ratio (cache hit rate) by extracting access log data (if we record the cache status in our access log):

# Count the frequency of each cache status
awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

A healthy production hit rate ratio for cacheable endpoints is above 80%. If our hit rate is low, check whether our $request_uri key format is too specific (e.g., including unique random user query string parameters) which makes Nginx treat every request as different data.


Summary and Best Practices #

  • Use Immutable on Hashed Assets: Apply Cache-Control "public, immutable" only to static files whose filenames include unique hashes to cut network validation traffic.
  • Enable proxy_cache_lock: Always use proxy_cache_lock on; on high-traffic servers to protect backend servers from Cache Stampede attacks.
  • Apply stale caching: Use proxy_cache_use_stale to improve our website’s availability stability when the backend experiences failures.
  • Secure Caching with Bypass: Always bypass the cache for non-GET requests (POST, PUT, DELETE) and requests including client authentication tokens.
  • Use Fast Disks: Place the proxy_cache_path directory on SSD storage media or RAM (tmpfs) for maximum cache data read/write speed.

← Previous: Gzip Compression   Next: Keepalive →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact