Cache Rendered Pages
templ has no built-in page cache. There is no CacheHandler in the runtime - caching rendered HTML is a roll-your-own job, and the basic version is a few dozen lines.
Roll-your-own map cache
Rendered output is just bytes written to an io.Writer, so a mutex-guarded map[string][]byte keyed by route works:
type pageCache struct {
mu sync.RWMutex
pages map[string][]byte
}
func (c *pageCache) get(key string) ([]byte, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
body, ok := c.pages[key]
return body, ok
}
func (c *pageCache) set(key string, body []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.pages[key] = body
}
A handler renders once, then replays:
func cachedHome(cache *pageCache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if body, ok := cache.get(r.URL.Path); ok {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(body)
return
}
var buf bytes.Buffer
if err := home().Render(r.Context(), &buf); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cache.set(r.URL.Path, buf.Bytes())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(buf.Bytes())
}
}
Invalidate from the code that knows content changed - typically the daemon that published it:
func (c *pageCache) invalidate(paths ...string) {
c.mu.Lock()
defer c.mu.Unlock()
for _, p := range paths {
delete(c.pages, p)
}
}
// on publish: the daemon knows exactly which routes got stale
cache.invalidate("/daily/2026-10-06", "/topic/politics", "/")
Tip: A rendered page is just bytes into a buffer, so render cost is rarely the problem. If a cached page feels slow, profile the data layer that feeds the component, not the render.
This pattern is what Next.js ISR does, minus the machinery: render-to-buffer plus invalidation. Add an LRU bound (
hashicorp/golang-lru) or entry timestamps if the route space is unbounded.
Gotcha: A per-process cache does not survive restarts and is not shared across replicas. For heavier needs, put a third-party cache in front of the app instead of growing this one.
Cache-Control for CDN and browser
For pages that are identical for everyone, set explicit headers and let a CDN or the browser do the caching instead of your process:
w.Header().Set("Cache-Control", "public, max-age=300")
This composes with the map cache: short max-age windows hide stale entries between invalidations.
No-store in development
Live reload needs the browser to refetch assets on every save. Add a no-store middleware for assets during development:
func noStore(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
Without it, stale CSS and JS make live reload look broken when it is actually the browser cache.