28.3 partialCached: The Single Biggest Performance Win
Alright, let’s get down to brass tacks. If you’re building a site of any real size with Hugo, you’ve probably noticed your build times starting to creep up from a blink to a coffee break. You’re running hugo server and then changing a single Markdown file, only to watch Hugo thoughtfully re-render… well, everything. It’s polite, but it’s also absurdly inefficient. This is where partialCached comes in, and it is, without a shred of hyperbole, the single most effective tool in your arsenal for slamming the brakes on runaway build times. Forget magic tricks; this is simple, brutal, and effective engineering.
The core problem is one of scope. When you change content/posts/my-great-post.md, Hugo’s default behavior is to re-render every single page that might be connected to it. This is because your layouts are likely littered with calls to {{ partial "my-gantry-crane-of-a-sidebar.html" . }} or similar. That partial might do some heavy lifting: querying your entire site taxonomy, sorting all your posts by date, and building a massive HTML structure. And Hugo, being thorough, recalculates it for every page on every change. It’s like recalculating the entire national budget every time you buy a coffee.
partialCached stops this madness. It tells Hugo: “Hey, for this specific input, you’ve already done this work. Don’t do it again. Just serve the cached result.” The performance win isn’t just incremental; it’s often orders of magnitude.
How It Works: The Vending Machine Analogy
Think of a regular partial call as a personal chef who prepares a fresh meal from scratch every single time you ask. partialCached is a vending machine. You press a button (provide a cache key), and if the vending machine has a sandwich for that key, it gives it to you instantly. If not, it makes the sandwich once, stores it, and then gives you the pre-made one for every subsequent request for that key.
The syntax is simple:
{{ partialCached "sidebar.html" . }}
But this basic form is often useless. It caches the partial, but it uses the entire context (the dot, .) as the cache key. Since the dot is different for every page (it’s the page context), you get a unique cache entry per page, which defeats the purpose. The real power comes when you provide your own, more specific cache key.
Crafting a Sane Cache Key
The secret sauce is the optional third (and fourth, etc.) parameter, which defines the cache key.
{{ partialCached "site-footer.html" . }}
{{ partialCached "recent-posts.html" . "homepage" }}
{{ partialCached "tag-cloud.html" . .Section }}
{{ partialCached "content-header.html" . .File.UniqueID }}
Let’s break those down:
site-footer.html: This is the naive approach. It will cache, but a separate cache will be created for every single page. Probably not what you want for a global footer.recent-posts.html: Here, we’ve pinned the cache key to the string"homepage". This means the partial will be rendered once and then reused on every single page that calls it with this key. This is perfect for a global component that is truly identical across the entire site.tag-cloud.html: This is a brilliant use case. Let’s say your tag cloud looks different per section (e.g., the “posts” section vs. the “projects” section). Using.Sectionas the key means Hugo will create one cached version for the “posts” section and reuse it on all post pages, and one for the “projects” section for all project pages.content-header.html: This is the gold standard for page-specific content. You want to cache this complex partial, but it’s unique to each piece of content. Using.File.UniqueID(a Hugo-provided unique identifier for the content file) as the key means it will be cached on the first render for this specific page and instantly retrieved on subsequent builds. This is a huge win for large list pages that might loop over hundreds of items.
The Gotchas: When Your Cache Bites You
This power comes with responsibility. The most common and frustrating pitfall is cache invalidation. Hugo’s cache is build-specific. If you change the partial template itself (sidebar.html), Hugo is smart enough to bust the entire cache for that partial and re-render it everywhere on the next build. This is brilliant.
But. If the content driving your partial changes outside of that template, Hugo cannot know. This is the critical nuance.
Imagine your recent-posts.html partial:
{{ $recent := where site.RegularPages "Type" "in" "posts" | first 5 }}
<ul>
{{ range $recent }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
You cache it with {{ partialCached "recent-posts.html" . "global-recent" }}. You publish a new post. What happens? Nothing. The cache is still valid from Hugo’s perspective. The template file hasn’t changed, and the cache key hasn’t changed. Your new post will not appear in the list until you force a cache bust, typically by touching the template file or doing a full rebuild with hugo --ignoreCache.
The solution? Build the cache key from the content you’re querying. You need a key that changes when the underlying data changes.
{{ $recent := where site.RegularPages "Type" "in" "posts" | first 5 }}
{{ $cacheKey := print "recent-posts-" (delimit (seq 5 | each | string) "-") }}
{{ partialCached "recent-posts.html" $recent $cacheKey }}
Here, the key is built from the unique IDs of the top 5 posts. If a new post enters the top 5, the sequence of IDs changes, the cache key changes, and the partial is forced to re-render. It’s more work, but it’s the only way to be truly safe. For simpler cases, using the .Lastmod date of the section can be a good enough proxy.
Use partialCached aggressively on every partial that does non-trivial work. Start with global components, then move to section-specific components, and finally tackle item-level components. Profile your build with hugo --templateMetrics --minify to see where the slow parts are and attack them with this tool. It’s not a magic wand, but it’s the closest thing we’ve got.