Skip to content

Commit 538cff5

Browse files
committed
docs: update all documentation and landing page for preload performance
- README: new end-to-end benchmarks (137k req/sec), preload/gc_percent config and env vars, updated architecture diagram with path cache - CLI.md: add --preload and --gc-percent to flag reference - USER_GUIDE.md: new 'Preloading for Maximum Performance' section, GC tuning guide, Docker preload example, updated SIGHUP docs - Landing page: hero stats show 137k req/sec, benchmark table replaces old Docker numbers with localhost results (beats Bun), updated feature cards, config tabs, structured data, and meta tags
1 parent 98e782a commit 538cff5

4 files changed

Lines changed: 178 additions & 94 deletions

File tree

CLI.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ Grouped by concern for readability. All flags are optional; unset flags do not o
186186
|------|------|---------|--------------|
187187
| `--host` | string | `` (all interfaces) | `server.addr` (host part) |
188188
| `--port`, `-p` | int | `8080` | `server.addr` (port part) |
189+
| `--redirect-host` | string || `server.redirect_host` |
189190
| `--tls-cert` | string || `server.tls_cert` |
190191
| `--tls-key` | string || `server.tls_key` |
191192
| `--tls-port` | int | `8443` | `server.tls_addr` (port part) |
@@ -205,6 +206,8 @@ Grouped by concern for readability. All flags are optional; unset flags do not o
205206
|------|------|---------|--------------|
206207
| `--no-cache` | bool | `false` | `cache.enabled = false` |
207208
| `--cache-size` | string | `256MB` | `cache.max_bytes` (parses `256MB`, `64MB`, `1GB`) |
209+
| `--preload` | bool | `false` | `cache.preload` — load all files into cache at startup |
210+
| `--gc-percent` | int | `0` | `cache.gc_percent` — Go GC target % (0 = default; try 400 for throughput) |
208211

209212
#### Compression
210213

@@ -244,6 +247,7 @@ static-web --dir-listing --no-dotfile-block ~/Downloads
244247

245248
# Serve with TLS (HTTPS on :443, HTTP redirect on :80)
246249
static-web --port 80 --tls-port 443 \
250+
--redirect-host static.example.com \
247251
--tls-cert /etc/ssl/cert.pem \
248252
--tls-key /etc/ssl/key.pem \
249253
./public
@@ -265,6 +269,9 @@ static-web
265269
# Disable caching (useful during local development to see file changes immediately)
266270
static-web --no-cache ./dist
267271

272+
# Maximum throughput: preload all files + tune GC
273+
static-web --preload --gc-percent 400 ./dist
274+
268275
# Print version info
269276
static-web version
270277
```
@@ -385,7 +392,7 @@ The CLI was implemented using Go stdlib `flag.FlagSet` — no external framework
385392
- **`--host` + `--port` merging**: `net.SplitHostPort` / `net.JoinHostPort` used to decompose and reconstruct `server.addr`.
386393
- **`parseBytes()`**: a small helper that parses `256MB`, `1GB`, etc. with `B`/`KB`/`MB`/`GB` suffixes (case-insensitive).
387394
- **`//go:embed config.toml.example`**: the example config is embedded in `cmd/static-web/` at compile time. The binary is fully self-contained.
388-
- **`--quiet`**: passes `io.Discard` to a `loggingMiddlewareWithWriter` variant, suppressing access log output with zero overhead.
395+
- **`--quiet`**: skips access-log middleware entirely, removing per-request logging overhead.
389396
- **`--verbose`**: calls `logConfig(cfg)` after all overrides are applied, so you see the final resolved values.
390397
- **Version injection**: `internal/version.Version`, `Commit`, `Date` are set via `-ldflags` at build time. Default to `"dev"`, `"none"`, `"unknown"` for `go run`.
391398

README.md

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ static-web --help
5353

5454
| Feature | Detail |
5555
|---------|--------|
56-
| **In-memory LRU cache** | Size-bounded, byte-accurate; zero-alloc hot path (~27 ns/op) |
56+
| **In-memory LRU cache** | Size-bounded, byte-accurate; ~28 ns/op lookup with 0 allocations. Optional startup preload for instant cache hits. |
5757
| **gzip compression** | On-the-fly via pooled `gzip.Writer`; pre-compressed `.gz`/`.br` sidecar support |
5858
| **HTTP/2** | Automatic ALPN negotiation when TLS is configured |
5959
| **Conditional requests** | ETag, `304 Not Modified`, `If-Modified-Since`, `If-None-Match` |
@@ -68,7 +68,7 @@ static-web --help
6868
| **Symlink escape prevention** | `EvalSymlinks` re-verified against root; symlinks pointing outside root are blocked |
6969
| **CORS** | Configurable per-origin or wildcard (`*` emits literal `*`, never reflected) |
7070
| **Graceful shutdown** | SIGTERM/SIGINT drains in-flight requests with configurable timeout |
71-
| **Live cache flush** | SIGHUP flushes the in-memory cache without downtime |
71+
| **Live cache flush** | SIGHUP flushes both the in-memory file cache and the path-safety cache without downtime |
7272

7373
---
7474

@@ -91,18 +91,13 @@ HTTP request
9191
│ • Method whitelist (GET/HEAD/OPTIONS only) │
9292
│ • Security headers (set BEFORE path check) │
9393
│ • PathSafe: null bytes, path.Clean, EvalSymlinks│
94+
│ • Path-safety cache (sync.Map, pre-warmed) │
9495
│ • Dotfile blocking │
9596
│ • CORS (preflight + per-origin or wildcard *) │
9697
│ • Injects validated path into context │
9798
└────────┬────────────────────────────────────────┘
9899
99100
┌────────▼────────────────────────────────────────┐
100-
│ headers.Middleware │
101-
│ • 304 Not Modified (ETag, If-Modified-Since) │
102-
│ • Cache-Control, immutable pattern matching │
103-
└────────┬────────────────────────────────────────┘
104-
105-
┌────────▼────────────────────────────────────────┐
106101
│ compress.Middleware │
107102
│ • lazyGzipWriter: decides at first Write() │
108103
│ • Skips 1xx/204/304, non-compressible types │
@@ -111,10 +106,12 @@ HTTP request
111106
112107
┌────────▼────────────────────────────────────────┐
113108
│ handler.FileHandler │
114-
│ • Cache hit → serve from memory (zero os.Stat) │
109+
│ • Cache hit → direct w.Write() fast path │
110+
│ • Range/conditional → http.ServeContent │
115111
│ • Cache miss → os.Stat → disk read → cache put │
116112
│ • Large files (> max_file_size) bypass cache │
117113
│ • Encoding negotiation: brotli > gzip > plain │
114+
│ • Preloaded files served instantly on startup │
118115
│ • Custom 404 page (path-validated) │
119116
└─────────────────────────────────────────────────┘
120117
```
@@ -125,32 +122,49 @@ HTTP request
125122
GET /app.js
126123
127124
├─ cache.Get("/app.js") hit?
128-
│ YES → serveFromCache (no syscall) → done
125+
│ YES → serveFromCache (direct w.Write, no syscall) → done
129126
130127
└─ NO → resolveIndexPath → cache.Get(canonicalURL) hit?
131128
YES → serveFromCache → done
132129
NO → os.Stat → os.ReadFile → cache.Put → serveFromCache
133130
```
134131

132+
When `preload = true`, every eligible file is loaded into cache at startup. The path-safety cache (`sync.Map`) is also pre-warmed, so the very first request for any preloaded file skips both filesystem I/O and `EvalSymlinks`.
133+
135134
---
136135

137136
## Performance
138137

139-
Benchmark numbers on Apple M2 Pro (`go test -bench=. -benchtime=5s`):
138+
### End-to-end HTTP benchmarks
139+
140+
Measured on Apple M2 Pro, localhost (no Docker), serving 3 small static files via `bombardier -c 50 -n 100000`:
141+
142+
| Server | Avg Req/sec | p50 Latency | p99 Latency |
143+
|--------|-------------|-------------|-------------|
144+
| **static-web** (preload + GC 400) | **~137,000** | **321 µs** | **1.18 ms** |
145+
| **static-web** (default config) | ~76,000 | 580 µs | 2.40 ms |
146+
| Bun (native static serve) | ~129,000 | 361 µs | 0.84 ms |
147+
148+
With `preload = true` and `gc_percent = 400`, static-web beats Bun's native static serving by ~6% in throughput.
149+
150+
### Micro-benchmarks
151+
152+
Measured on Apple M2 Pro (`go test -bench=. -benchtime=5s`):
140153

141154
| Benchmark | ops/s | ns/op | allocs/op |
142155
|-----------|-------|-------|-----------|
143-
| `BenchmarkCacheGet` | 87–131 M | 27 | 0 |
144-
| `BenchmarkCachePut` | 42–63 M | 57 | 0 |
145-
| `BenchmarkCacheGetParallel` | 15–25 M | 142–147 | 0 |
146-
| `BenchmarkHandler_CacheHit` || ~5,840 ||
156+
| `BenchmarkCacheGet` | 35–42 M | 28–29 | 0 |
157+
| `BenchmarkCacheGetParallel` | 6–8 M | 139–148 | 0 |
147158

148-
Key design decisions driving these numbers:
159+
### Key design decisions
149160

161+
- **Preload at startup**: `preload = true` reads all eligible files into RAM before the first request — eliminating cold-miss latency.
162+
- **Direct `w.Write()` fast path**: cache hits bypass `http.ServeContent` entirely; pre-formatted `Content-Type` and `Content-Length` headers are assigned directly.
163+
- **Path-safety cache**: `sync.Map`-based cache eliminates per-request `filepath.EvalSymlinks` syscalls. Pre-warmed from preload.
164+
- **GC tuning**: `gc_percent = 400` reduces garbage collection frequency — the hot path is allocation-free, but `net/http` internals allocate per-request.
150165
- **Cache-before-stat**: `os.Stat` is never called on a cache hit — the hot path is pure memory.
151166
- **Zero-alloc `AcceptsEncoding`**: walks the `Accept-Encoding` header byte-by-byte without `strings.Split`.
152167
- **Pooled `sync.Pool`**: both `gzip.Writer` and `statusResponseWriter` are pooled.
153-
- **`filepath.Abs` at startup**: computed once during construction, never per-request.
154168
- **Pre-computed `ETagFull`**: the `W/"..."` string is built when the file is cached.
155169

156170
---
@@ -211,6 +225,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start
211225
|-----|------|---------|-------------|
212226
| `addr` | string | `:8080` | HTTP listen address |
213227
| `tls_addr` | string | `:8443` | HTTPS listen address |
228+
| `redirect_host` | string || Canonical host used for HTTP→HTTPS redirects |
214229
| `tls_cert` | string || Path to TLS certificate (PEM) |
215230
| `tls_key` | string || Path to TLS private key (PEM) |
216231
| `read_header_timeout` | duration | `5s` | Slowloris protection |
@@ -232,9 +247,11 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start
232247
| Key | Type | Default | Description |
233248
|-----|------|---------|-------------|
234249
| `enabled` | bool | `true` | Toggle in-memory LRU cache |
250+
| `preload` | bool | `false` | Load all eligible files into cache at startup |
235251
| `max_bytes` | int | `268435456` | Cache size cap (bytes) |
236252
| `max_file_size` | int | `10485760` | Max file size to cache (bytes) |
237253
| `ttl` | duration | `0` | Entry TTL (0 = no expiry; flush with SIGHUP) |
254+
| `gc_percent` | int | `0` | Go GC target percentage (0 = use Go default of 100) |
238255

239256
### `[compression]`
240257

@@ -276,6 +293,7 @@ All environment variables override the corresponding TOML setting. Useful for co
276293
|----------|-------------|
277294
| `STATIC_SERVER_ADDR` | `server.addr` |
278295
| `STATIC_SERVER_TLS_ADDR` | `server.tls_addr` |
296+
| `STATIC_SERVER_REDIRECT_HOST` | `server.redirect_host` |
279297
| `STATIC_SERVER_TLS_CERT` | `server.tls_cert` |
280298
| `STATIC_SERVER_TLS_KEY` | `server.tls_key` |
281299
| `STATIC_SERVER_READ_HEADER_TIMEOUT` | `server.read_header_timeout` |
@@ -287,9 +305,11 @@ All environment variables override the corresponding TOML setting. Useful for co
287305
| `STATIC_FILES_INDEX` | `files.index` |
288306
| `STATIC_FILES_NOT_FOUND` | `files.not_found` |
289307
| `STATIC_CACHE_ENABLED` | `cache.enabled` |
308+
| `STATIC_CACHE_PRELOAD` | `cache.preload` |
290309
| `STATIC_CACHE_MAX_BYTES` | `cache.max_bytes` |
291310
| `STATIC_CACHE_MAX_FILE_SIZE` | `cache.max_file_size` |
292311
| `STATIC_CACHE_TTL` | `cache.ttl` |
312+
| `STATIC_CACHE_GC_PERCENT` | `cache.gc_percent` |
293313
| `STATIC_COMPRESSION_ENABLED` | `compression.enabled` |
294314
| `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` |
295315
| `STATIC_COMPRESSION_LEVEL` | `compression.level` |
@@ -307,12 +327,13 @@ Set `tls_cert` and `tls_key` to enable HTTPS:
307327
[server]
308328
addr = ":80"
309329
tls_addr = ":443"
330+
redirect_host = "static.example.com"
310331
tls_cert = "/etc/ssl/certs/server.pem"
311332
tls_key = "/etc/ssl/private/server.key"
312333
```
313334

314335
When TLS is configured:
315-
- HTTP requests on `addr` are automatically **redirected** to `tls_addr` with `301 Moved Permanently`.
336+
- HTTP requests on `addr` are automatically **redirected** to HTTPS. Set `redirect_host` when `tls_addr` listens on all interfaces (for example `:443`) so redirects use a canonical host instead of the incoming `Host` header.
316337
- **HTTP/2** is enabled automatically via ALPN negotiation.
317338
- **HSTS** (`Strict-Transport-Security`) is added to all HTTPS responses (configurable max-age).
318339
- Minimum TLS version is **1.2**; preferred cipher suites are ECDHE+AES-256-GCM and ChaCha20-Poly1305.
@@ -348,9 +369,9 @@ make precompress # runs gzip and brotli on all .js/.css/.html/.json/.svg
348369
|--------|--------|
349370
| `SIGTERM` | Graceful shutdown (drains in-flight requests up to `shutdown_timeout`) |
350371
| `SIGINT` | Graceful shutdown |
351-
| `SIGHUP` | Flush in-memory cache; re-reads config pointer in `main` |
372+
| `SIGHUP` | Flush in-memory file cache and path-safety cache; re-reads config pointer in `main` |
352373

353-
> **Note**: SIGHUP reloads the config pointer in `main` but the live middleware chain holds references to the old config. A full restart is required for config changes to take effect. SIGHUP is useful for flushing the cache without downtime.
374+
> **Note**: SIGHUP reloads the config pointer in `main` but the live middleware chain holds references to the old config. A full restart is required for config changes to take effect. SIGHUP is useful for flushing both the file cache and the path-safety cache without downtime.
354375
355376
---
356377

@@ -400,5 +421,4 @@ go test -race ./... # all tests, race-free
400421
| Limitation | Detail |
401422
|------------|--------|
402423
| **Brotli on-the-fly** | Not implemented. Only pre-compressed `.br` sidecar files are served. |
403-
| **Cache TTL not enforced** | `cache.ttl` is parsed but the expiry logic is not yet implemented. Use SIGHUP to flush manually. |
404424
| **SIGHUP config reload** | Reloads the config struct pointer in `main` only. Live middleware chains hold old references — full restart required for config changes to propagate. |

0 commit comments

Comments
 (0)