Skip to content

Commit 71847e4

Browse files
committed
perf(server): migrate HTTP layer from net/http to fasthttp
Replace the entire net/http stack with valyala/fasthttp for ~85% higher throughput on cached static file serving (140k req/s vs 76k on net/http). Key changes: - http.Handler/HandlerFunc → fasthttp.RequestHandler throughout - http.ResponseWriter + *http.Request → single *fasthttp.RequestCtx - http.Server → fasthttp.Server with Serve(ln) / ShutdownWithContext - Custom Range request impl (parseRange/serveRange) replaces http.ServeContent - Compress middleware becomes post-processing (compress body after handler) - Security middleware uses manual status/body writes to preserve headers (fasthttp ctx.Error resets all headers) - CachedFile header fields changed from []string to string - Listener uses tcp4 to match fasthttp internals and avoid dual-stack overhead - net/http retained only for http.DetectContentType (standalone utility) Benchmark (bare-metal, 100 conns, 100k reqs, preload+gc400): static-web (fasthttp): 140,662 req/s p50=619µs p99=2.46ms 469 MB/s Bun native static: 90,346 req/s p50=1.05ms p99=2.33ms 306 MB/s Dependencies added: fasthttp v1.69.0, andybalholm/brotli v1.2.0, klauspost/compress v1.18.2, valyala/bytebufferpool v1.0.0
1 parent d174c26 commit 71847e4

18 files changed

Lines changed: 1378 additions & 1032 deletions

File tree

cmd/static-web/main.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"fmt"
1616
"log"
1717
"net"
18-
"net/http"
1918
"os"
2019
"path/filepath"
2120
"runtime"
@@ -30,6 +29,7 @@ import (
3029
"github.com/BackendStack21/static-web/internal/security"
3130
"github.com/BackendStack21/static-web/internal/server"
3231
"github.com/BackendStack21/static-web/internal/version"
32+
"github.com/valyala/fasthttp"
3333
)
3434

3535
//go:embed config.toml.example
@@ -185,13 +185,16 @@ func runServe(args []string) {
185185
var pathCache *security.PathCache
186186
if c != nil && cfg.Cache.Preload {
187187
pcfg := cache.PreloadConfig{
188-
MaxFileSize: cfg.Cache.MaxFileSize,
189-
IndexFile: cfg.Files.Index,
190-
BlockDotfiles: cfg.Security.BlockDotfiles,
191-
CompressEnabled: cfg.Compression.Enabled,
192-
CompressMinSize: cfg.Compression.MinSize,
193-
CompressLevel: cfg.Compression.Level,
194-
CompressFn: compress.GzipBytes,
188+
MaxFileSize: cfg.Cache.MaxFileSize,
189+
IndexFile: cfg.Files.Index,
190+
BlockDotfiles: cfg.Security.BlockDotfiles,
191+
CompressEnabled: cfg.Compression.Enabled,
192+
CompressMinSize: cfg.Compression.MinSize,
193+
CompressLevel: cfg.Compression.Level,
194+
CompressFn: compress.GzipBytes,
195+
HTMLMaxAge: cfg.Headers.HTMLMaxAge,
196+
StaticMaxAge: cfg.Headers.StaticMaxAge,
197+
ImmutablePattern: cfg.Headers.ImmutablePattern,
195198
}
196199
stats := c.Preload(cfg.Files.Root, pcfg)
197200
if !effectiveQuiet {
@@ -208,7 +211,7 @@ func runServe(args []string) {
208211
}
209212

210213
// Build the full middleware + handler chain.
211-
var h http.Handler
214+
var h fasthttp.RequestHandler
212215
if effectiveQuiet {
213216
h = handler.BuildHandlerQuiet(cfg, c, pathCache)
214217
} else {

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,11 @@ go 1.26
55
require (
66
github.com/BurntSushi/toml v1.6.0
77
github.com/hashicorp/golang-lru/v2 v2.0.7
8+
github.com/valyala/fasthttp v1.69.0
9+
)
10+
11+
require (
12+
github.com/andybalholm/brotli v1.2.0 // indirect
13+
github.com/klauspost/compress v1.18.2 // indirect
14+
github.com/valyala/bytebufferpool v1.0.0 // indirect
815
)

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
22
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
4+
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
35
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
46
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
7+
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
8+
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
9+
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
10+
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
11+
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
12+
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=

internal/cache/cache.go

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package cache
55

66
import (
7+
"path"
78
"strconv"
89
"sync"
910
"sync/atomic"
@@ -18,6 +19,11 @@ const (
1819
// maxLRUItems is the maximum number of entries the underlying LRU may hold.
1920
// Byte-level eviction happens before this is reached in practice.
2021
maxLRUItems = 65536
22+
23+
// HTTPTimeFormat is the time format used for HTTP Date, Last-Modified,
24+
// and Expires headers. It is identical to the value of net/http.TimeFormat
25+
// but defined here to avoid importing net/http in the cache package.
26+
HTTPTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
2127
)
2228

2329
// CachedFile holds the content and metadata for a single cached file.
@@ -44,20 +50,102 @@ type CachedFile struct {
4450

4551
// Pre-formatted header values avoid per-request string formatting.
4652
// These are populated by InitHeaders() or by the preload path.
47-
CTHeader []string // e.g. {"text/html; charset=utf-8"}
48-
CLHeader []string // e.g. {"2943"} — raw data Content-Length
53+
// With fasthttp, headers are set via Set(key, value) taking plain strings;
54+
// an empty string means "not initialised".
55+
CTHeader string // e.g. "text/html; charset=utf-8"
56+
CLHeader string // e.g. "2943" — raw data Content-Length
57+
58+
// Pre-formatted cache/conditional headers (PERF-003).
59+
// Populated by InitHeaders(); the serving hot path assigns these
60+
// directly to the response header, skipping all formatting.
61+
ETagHeader string // e.g. `W/"abc123"`
62+
LastModHeader string // e.g. "Mon, 15 Jan 2024 10:00:00 GMT"
63+
VaryHeader string // e.g. "Accept-Encoding"
64+
CacheControlHeader string // e.g. "public, max-age=3600"
4965
}
5066

51-
// InitHeaders pre-formats the Content-Type and Content-Length header slices
52-
// so that the serving hot path can assign them directly to the header map
53-
// without allocating. This is idempotent.
67+
// InitHeaders pre-formats the Content-Type, Content-Length, ETag,
68+
// Last-Modified, and Vary header strings so that the serving hot path can
69+
// assign them directly without allocating (PERF-003).
70+
// This is idempotent.
5471
func (f *CachedFile) InitHeaders() {
55-
if f.CTHeader == nil {
56-
f.CTHeader = []string{f.ContentType}
72+
if f.CTHeader == "" {
73+
f.CTHeader = f.ContentType
74+
}
75+
if f.CLHeader == "" {
76+
f.CLHeader = strconv.FormatInt(f.Size, 10)
77+
}
78+
if f.ETagHeader == "" {
79+
etag := f.ETagFull
80+
if etag == "" {
81+
etag = `W/"` + f.ETag + `"`
82+
}
83+
f.ETagHeader = etag
84+
}
85+
if f.LastModHeader == "" {
86+
f.LastModHeader = f.LastModified.UTC().Format(HTTPTimeFormat)
87+
}
88+
if f.VaryHeader == "" {
89+
f.VaryHeader = "Accept-Encoding"
90+
}
91+
}
92+
93+
// InitCacheControl pre-formats the Cache-Control header for a specific URL
94+
// path and header configuration. This must be called after InitHeaders().
95+
// urlPath is used to determine HTML vs static max-age; isHTML reports whether
96+
// the file is an HTML document; immutablePattern is the glob for immutable files.
97+
func (f *CachedFile) InitCacheControl(urlPath string, htmlMaxAge, staticMaxAge int, immutablePattern string) {
98+
if f.CacheControlHeader != "" {
99+
return
57100
}
58-
if f.CLHeader == nil {
59-
f.CLHeader = []string{strconv.FormatInt(f.Size, 10)}
101+
maxAge := staticMaxAge
102+
if isHTMLContent(urlPath, f.ContentType) {
103+
maxAge = htmlMaxAge
104+
}
105+
if maxAge == 0 {
106+
f.CacheControlHeader = "no-cache"
107+
} else {
108+
cc := "public, max-age=" + strconv.Itoa(maxAge)
109+
if immutablePattern != "" && matchesImmutable(urlPath, immutablePattern) {
110+
cc += ", immutable"
111+
}
112+
f.CacheControlHeader = cc
113+
}
114+
}
115+
116+
// isHTMLContent reports whether the given URL path + content type indicates HTML.
117+
func isHTMLContent(urlPath, contentType string) bool {
118+
if len(contentType) >= 9 {
119+
// Fast prefix check before calling strings functions.
120+
if contentType[0] == 't' && contentType[4] == '/' && contentType[5] == 'h' {
121+
return true // "text/html..."
122+
}
123+
}
124+
// Fallback to extension check.
125+
for i := len(urlPath) - 1; i >= 0; i-- {
126+
if urlPath[i] == '.' {
127+
ext := urlPath[i:]
128+
return ext == ".html" || ext == ".htm" ||
129+
ext == ".HTML" || ext == ".HTM"
130+
}
131+
}
132+
return false
133+
}
134+
135+
// matchesImmutable checks if the base filename matches the immutable glob.
136+
func matchesImmutable(urlPath, pattern string) bool {
137+
// Extract base name without filepath.Base allocation.
138+
base := urlPath
139+
if i := len(urlPath) - 1; i >= 0 {
140+
for i >= 0 && urlPath[i] != '/' {
141+
i--
142+
}
143+
base = urlPath[i+1:]
60144
}
145+
// filepath.Match is unavoidable for glob support but is only called
146+
// at cache-population time, never on the hot path.
147+
matched, _ := path.Match(pattern, base)
148+
return matched
61149
}
62150

63151
// totalSize returns the approximate byte footprint of the entry.

internal/cache/preload.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package cache
22

33
import (
44
"crypto/sha256"
5-
"fmt"
5+
"encoding/hex"
66
"io/fs"
77
"mime"
88
"net/http"
@@ -44,6 +44,11 @@ type PreloadConfig struct {
4444
// CompressFn is an optional function that gzip-compresses src.
4545
// When nil, no gzip variants are produced.
4646
CompressFn func(src []byte, level int) ([]byte, error)
47+
48+
// Header config for pre-computing Cache-Control at preload time (PERF-003).
49+
HTMLMaxAge int
50+
StaticMaxAge int
51+
ImmutablePattern string
4752
}
4853

4954
// Preload walks root and loads every eligible regular file into the cache.
@@ -132,6 +137,7 @@ func (c *Cache) Preload(root string, cfg PreloadConfig) PreloadStats {
132137
}
133138

134139
cached.InitHeaders()
140+
cached.InitCacheControl(urlKey, cfg.HTMLMaxAge, cfg.StaticMaxAge, cfg.ImmutablePattern)
135141

136142
c.Put(urlKey, cached)
137143
stats.Files++
@@ -182,9 +188,11 @@ func detectMIMEType(filePath string, data []byte) string {
182188
}
183189

184190
// computeFileETag returns the first 16 hex characters of sha256(data).
191+
// Uses hex.EncodeToString on the first 8 bytes instead of fmt.Sprintf
192+
// to avoid formatting the full 32-byte hash and then truncating (PERF-004).
185193
func computeFileETag(data []byte) string {
186194
sum := sha256.Sum256(data)
187-
return fmt.Sprintf("%x", sum)[:16]
195+
return hex.EncodeToString(sum[:8])
188196
}
189197

190198
// isCompressibleType reports whether the MIME type is eligible for compression.

0 commit comments

Comments
 (0)