Static File Serving

Caching, compression, and efficient file delivery

Why Servers Excel at Static Files

Dedicated web servers like Nginx are exceptionally efficient at serving static files. They use operating system features that your application code typically cannot:

sendfile() and Zero-Copy

Normally, serving a file requires copying data twice: from disk to kernel buffer, then from kernel to application, then back to kernel for sending. The sendfile() system call skips the application entirely:

Traditional:                      sendfile():
┌──────────┐                      ┌──────────┐
│   Disk   │                      │   Disk   │
└────┬─────┘                      └────┬─────┘
     │ read()                          │
     ▼                                 │ sendfile()
┌──────────┐                           │
│  Kernel  │                           │
│  Buffer  │                           ▼
└────┬─────┘                      ┌──────────┐
     │ copy                       │  Kernel  │
     ▼                            │  Buffer  │
┌──────────┐                      └────┬─────┘
│   App    │                           │
│  Buffer  │                           │
└────┬─────┘                           │
     │ write()                         │
     ▼                                 ▼
┌──────────┐                      ┌──────────┐
│  Socket  │                      │  Socket  │
└──────────┘                      └──────────┘

4 copies, 4 context switches      2 copies, 2 context switches
				
sendfile() eliminates unnecessary data copying

This is why Nginx serving static files can be 10x faster than your Node.js application doing the same thing—it's not about the language, it's about using the right system calls.

Caching Headers

Proper caching headers let browsers and CDNs avoid re-downloading unchanged files. This is the single biggest performance win for static assets.

Cache-Control

Directive Meaning Example
max-age=N Cache for N seconds max-age=31536000 (1 year)
public CDNs and proxies can cache Shared cache allowed
private Only browser can cache User-specific content
no-cache Revalidate before using cache Always check for updates
no-store Never cache Sensitive data
immutable Will never change Versioned assets
server { # Versioned assets (hashed filenames) - cache forever location ~* \.(js|css)$ { expires 1y; add_header Cache-Control "public, immutable"; } # Images - cache for 30 days location ~* \.(jpg|jpeg|png|gif|webp|svg|ico)$ { expires 30d; add_header Cache-Control "public"; } # HTML - always revalidate location ~* \.html$ { expires -1; add_header Cache-Control "no-cache"; } # Fonts - cache for 1 year location ~* \.(woff|woff2|ttf|otf|eot)$ { expires 1y; add_header Cache-Control "public"; add_header Access-Control-Allow-Origin *; } }
# Enable mod_expires ExpiresActive On # Versioned assets - cache forever <FilesMatch "\.(js|css)$"> ExpiresDefault "access plus 1 year" Header set Cache-Control "public, immutable" </FilesMatch> # Images - cache 30 days <FilesMatch "\.(jpg|jpeg|png|gif|webp|svg|ico)$"> ExpiresDefault "access plus 30 days" Header set Cache-Control "public" </FilesMatch> # HTML - always revalidate <FilesMatch "\.html$"> ExpiresDefault "access plus 0 seconds" Header set Cache-Control "no-cache" </FilesMatch>

ETag and Last-Modified

For cache validation, servers send identifiers that browsers use to check if content changed:

# First request GET /style.css HTTP/1.1 HTTP/1.1 200 OK ETag: "abc123" Last-Modified: Wed, 15 Nov 2024 10:00:00 GMT Content-Length: 5432 # Subsequent request (browser sends cached values) GET /style.css HTTP/1.1 If-None-Match: "abc123" If-Modified-Since: Wed, 15 Nov 2024 10:00:00 GMT # If unchanged: HTTP/1.1 304 Not Modified (no body - browser uses cached version)

ETag vs Last-Modified: ETag is more precise (based on content hash or version), while Last-Modified uses timestamps (1-second granularity). Modern servers send both. ETag takes precedence when both are present.

Compression

Text-based files (HTML, CSS, JS, JSON) compress extremely well. A typical JavaScript bundle can shrink by 70-80%.

Gzip vs Brotli

Algorithm Compression Speed Browser Support
Gzip Good Fast Universal
Brotli 15-20% better Slower to compress Modern browsers (HTTPS only)
# Gzip compression gzip on; gzip_vary on; gzip_min_length 1024; gzip_comp_level 6; gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml; # Brotli (requires ngx_brotli module) brotli on; brotli_comp_level 6; brotli_types text/plain text/css text/javascript application/javascript application/json; # Serve pre-compressed files if available gzip_static on; brotli_static on;
# Enable mod_deflate for gzip <IfModule mod_deflate.c> AddOutputFilterByType DEFLATE text/plain AddOutputFilterByType DEFLATE text/css AddOutputFilterByType DEFLATE text/javascript AddOutputFilterByType DEFLATE application/javascript AddOutputFilterByType DEFLATE application/json AddOutputFilterByType DEFLATE image/svg+xml # Don't compress already compressed files SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp)$ no-gzip </IfModule> # Brotli (requires mod_brotli) <IfModule mod_brotli.c> AddOutputFilterByType BROTLI_COMPRESS text/plain text/css AddOutputFilterByType BROTLI_COMPRESS application/javascript </IfModule>

Pre-compressed Files

For maximum compression without CPU overhead, pre-compress files at build time:

# Build step: create compressed versions gzip -k -9 dist/*.js dist/*.css brotli -k dist/*.js dist/*.css # Results in: dist/ ├── app.js ├── app.js.gz # Gzip version ├── app.js.br # Brotli version ├── style.css ├── style.css.gz └── style.css.br

With gzip_static on and brotli_static on, Nginx will serve the pre-compressed version if available and the client supports it.

Byte-Range Requests

Range requests allow clients to download partial files—essential for video seeking and resumable downloads:

# Request specific bytes of a file GET /video.mp4 HTTP/1.1 Range: bytes=1000000-1999999 # Server responds with partial content HTTP/1.1 206 Partial Content Content-Range: bytes 1000000-1999999/50000000 Content-Length: 1000000 [1MB of video data]

Nginx and Apache support range requests by default for static files. Ensure your video/audio files are served with Accept-Ranges: bytes header.

Why this matters: Without range request support, seeking in an HTML5 video requires downloading from the beginning. With range requests, the browser downloads only the needed segment, making seeking instant.

Cache Busting

When you set long cache times, you need a way to force browsers to download updated files. Common strategies:

1. Hashed Filenames (Recommended)

Build tools generate filenames based on content hash:

# Before: app.js # After: app.3a7b9c2d.js # When content changes, hash changes → new URL → forced download <script src="/assets/app.3a7b9c2d.js"></script>

2. Query String Versioning

<link href="/style.css?v=1.2.3" rel="stylesheet"> <script src="/app.js?v=20241115"></script>

Simpler but less reliable—some proxies ignore query strings when caching.

3. Path Versioning

<link href="/v3/style.css" rel="stylesheet"> # Or <link href="/assets/1.2.3/style.css" rel="stylesheet">

Complete Static File Configuration

server { listen 443 ssl http2; server_name static.example.com; root /var/www/static; # Enable sendfile for efficiency sendfile on; tcp_nopush on; tcp_nodelay on; # Gzip gzip on; gzip_static on; gzip_vary on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; # Default caching expires 7d; add_header Cache-Control "public"; # Hashed assets (immutable) location ~* \.[0-9a-f]{8,}\.(js|css)$ { expires 1y; add_header Cache-Control "public, immutable"; } # Security headers add_header X-Content-Type-Options "nosniff"; # CORS for fonts location ~* \.(woff2?|ttf|otf|eot)$ { add_header Access-Control-Allow-Origin *; } }

What's Next

Efficient static file serving often goes hand-in-hand with HTTPS. The next tutorial covers TLS certificates, secure configuration, and the mechanics of HTTPS.