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.