How to Implement a Template Cache in Go

Web
Parse Go templates once at startup using template.Must and store the result in a variable to avoid repeated parsing overhead.

The startup bottleneck

You write a simple HTTP handler. You load an HTML file, pass some data to it, and write the result to the response. It works perfectly during local testing. You deploy it, send a few hundred requests per second, and watch the CPU climb to 100 percent. The server isn't broken. It is just repeating the same expensive work thousands of times a second.

Go's template engine does not treat HTML as plain text. It reads every file, tokenizes the syntax, builds an abstract syntax tree, validates escaping rules, and compiles the structure into an executable graph. Doing this inside a request handler means you pay that compilation cost on every single call. The fix is straightforward: parse once at startup, store the compiled result, and reuse it forever.

Parse once. Execute forever.

What the template engine actually compiles

When you call template.ParseFiles, Go does not just open a file and read strings. It walks the document, identifies action tags like {{.Name}} and {{range .Items}}, resolves their types, and constructs a tree of nodes. Each node knows how to execute itself against a data context. The engine also injects HTML-escaping rules automatically when you use html/template, which prevents cross-site scripting by default.

Think of this process like baking a cake from scratch. You measure flour, crack eggs, mix batter, and wait for the oven. If you bake a new cake for every guest who walks through the door, you will never serve anyone. Caching means baking one cake, slicing it, and handing out plates. The *template.Template value is that sliced cake. It is ready to execute immediately, with zero parsing overhead.

The minimal caching pattern

The idiomatic approach lives at the package level. You declare a variable, parse the template during initialization, and wrap the call in template.Must. This pattern is safe because startup failures should crash the process anyway. A missing template file is a deployment error, not a runtime condition you can recover from gracefully.

Here is the simplest implementation:

package main

import (
	"html/template"
	"log"
	"net/http"
)

// templates holds the pre-compiled HTML template for reuse.
var templates = template.Must(template.ParseFiles("templates/home.html"))

// HomeHandler serves the cached template to every request.
func HomeHandler(w http.ResponseWriter, r *http.Request) {
	// Execute writes the rendered HTML directly to the response writer.
	if err := templates.Execute(w, map[string]string{"Title": "Dashboard"}); err != nil {
		// Log the error and return a 500 status. The verbose check is idiomatic.
		log.Printf("template execution failed: %v", err)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

The variable declaration runs before main starts. template.ParseFiles reads the disk, compiles the AST, and returns a *template.Template and an error. template.Must checks the error. If it is not nil, the function panics. The panic stops initialization, which is exactly what you want when a required asset is missing. If parsing succeeds, the variable holds a ready-to-execute template for the lifetime of the process.

How initialization and execution flow

Package-level variables initialize in dependency order. If your handler package imports the template package, the template variable compiles before any HTTP routes register. By the time the server starts listening, the template is already in memory.

When a request arrives, Execute takes the compiled template and a data context. It walks the AST, evaluates each action against the context, applies HTML escaping where needed, and streams the result to the http.ResponseWriter. No disk I/O. No regex matching. No tree construction. The CPU only does the minimal work of substituting values and writing bytes.

The community accepts the if err != nil { return err } pattern because it makes failure paths explicit. Template execution can still fail at runtime if the data context lacks a required field or if a custom function panics. Catching the error and returning a 500 response keeps the server alive while surfacing the problem in logs.

Panic at startup is better than a silent 500 error at 3 a.m.

A realistic multi-template setup

Real applications rarely serve a single file. You need layouts, partials, and route-specific pages. template.ParseGlob handles this cleanly by loading multiple files and grouping them by name. The engine automatically associates files with the same base name, and you can explicitly define a master layout using template.ParseFiles followed by ParseGlob.

Here is how a production-style setup looks:

package main

import (
	"html/template"
	"log"
	"net/http"
)

// templates holds a map of compiled templates keyed by route name.
var templates = template.Must(template.ParseGlob("templates/*.html"))

// RenderHandler picks the correct template and executes it.
func RenderHandler(w http.ResponseWriter, r *http.Request) {
	// Extract the template name from the URL path for demonstration.
	name := r.URL.Query().Get("page")
	if name == "" {
		name = "index"
	}

	// Lookup returns nil if the template was not found during parsing.
	tmpl := templates.Lookup(name)
	if tmpl == nil {
		http.Error(w, "template not found", http.StatusNotFound)
		return
	}

	// Execute the selected template with a simple data map.
	if err := tmpl.Execute(w, map[string]string{"Page": name}); err != nil {
		log.Printf("template %s failed: %v", name, err)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

ParseGlob reads every file matching the pattern, compiles them, and stores them in a single *template.Template value. The Lookup method retrieves a specific template by its base name. If you have base.html, index.html, and about.html, calling templates.Lookup("index") returns only the index template, but it still inherits any definitions from the parsed set.

You can also define a shared layout explicitly. Parse the layout first, then call ParseGlob on the same template value. The engine merges them, allowing route-specific templates to call {{template "header" .}} or {{define "content"}} blocks. This keeps your HTML DRY without adding runtime overhead.

Stale templates are a silent bug. Design your reload strategy before you deploy.

Pitfalls and compiler behavior

The most common mistake is placing template.ParseFiles inside the handler. The compiler will not stop you. It will compile fine, but your server will grind to a halt under load. The fix is moving the call to package level.

Another trap is modifying template files after the server starts. The cache never refreshes automatically. If you drop a new home.html into the directory, the running process continues serving the old version until you restart. This is intentional. Go favors explicit state over implicit magic. If you need hot reloading, you must implement it yourself with a file watcher, a mutex, and a reload endpoint. The standard library does not provide it because background file polling introduces race conditions and memory leaks.

The compiler enforces type safety around template data. If you pass a struct with unexported fields, the template engine silently ignores them. You will get empty output instead of a panic. The compiler will not warn you about this. It is a runtime behavior of the reflection-based execution engine. Always use exported fields or provide a custom Text/HTML method if you need control over rendering.

If you forget to import html/template, the compiler rejects the program with undefined: template. If you pass a non-string value to a function that expects a string, you get cannot use x (type int) as string value in argument. These errors are straightforward. Read them literally. Fix the type or add a conversion.

The worst template bug is the one that renders silently wrong data. Validate your context structure in tests, not in production logs.

When to cache and when to skip it

Use a package-level template.Must cache when your templates are static and deployed alongside the binary. Use template.ParseGlob when you need multiple routes to share a single compiled template set. Use a runtime reload pattern with a mutex and file watcher when your team edits templates frequently during development and cannot tolerate restarts. Use text/template when generating non-HTML output like SQL queries, YAML configs, or plain text logs. Skip caching entirely when templates are generated dynamically per request and never reused across calls.

The right cache depends on your deployment rhythm, not your traffic volume.

Where to go next