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
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.