Fragment Caching

Manual caching for expensive template operations

5 min read 1045 words

Use Kida's built-in{% cache %}directive for manual control over expensive template operations.

When to Use Fragment Caching

Cache fragments when you have:

  • Expensive computations (navigation trees, related posts)
  • Complex data processing (statistics, aggregations)
  • Repeated calculations across pages

Basic Caching

Simple Cache

{% cache "sidebar-nav" %}
  {{ build_nav_tree(site.pages) }}
{% end %}

Dynamic Cache Keys

Use expressions to create unique cache keys:

{% cache "related-" ~ post.id %}
  {% let related = site.pages
    |> where('tags', post.tags[0])
    |> take(5) %}
  <aside>
    {% for item in related %}
      <a href="{{ item.url }}">{{ item.title }}</a>
    {% end %}
  </aside>
{% end %}

Cache with TTL

Fragment cache uses a global TTL configured at the environment level. All cached fragments share the same expiration time set inbengal.yaml:

{% cache "stats" %}
  {{ calculate_site_stats() }}
{% end %}

Note: Per-key TTL (e.g.,ttl="1h") is not currently supported. All fragments use the environment-levelfragment_ttlsetting. See Configuration to set the global TTL.

Common Use Cases

{% cache "related-posts-" ~ page.id %}
  {% let related = site.pages
    |> where('type', 'blog')
    |> where('tags', page.tags[0] ?? '')
    |> where('id', '!=', page.id)
    |> sort_by('date', reverse=true)
    |> take(5) %}

  {% if related %}
    <aside class="related-posts">
      <h2>Related Posts</h2>
      <ul>
        {% for item in related %}
          <li>
            <a href="{{ item.url }}">{{ item.title }}</a>
            <span>{{ item.date | days_ago }} days ago</span>
          </li>
        {% end %}
      </ul>
    </aside>
  {% end %}
{% end %}
{% cache "nav-tree-" ~ site.nav_version %}
  {% let nav = get_nav_tree(page) %}
  <nav>
    {% for item in nav %}
      <a href="{{ item.url }}">{{ item.title }}</a>
    {% end %}
  </nav>
{% end %}

Site Statistics

{% cache "site-stats" %}
  {% let total_posts = site.pages |> where('type', 'blog') |> length %}
  {% let total_docs = site.pages |> where('type', 'doc') |> length %}
  {% let total_tags = site.tags |> length %}

  <div class="stats">
    <div class="stat">
      <span class="value">{{ total_posts }}</span>
      <span class="label">Posts</span>
    </div>
    <div class="stat">
      <span class="value">{{ total_docs }}</span>
      <span class="label">Docs</span>
    </div>
    <div class="stat">
      <span class="value">{{ total_tags }}</span>
      <span class="label">Tags</span>
    </div>
  </div>
{% end %}

Tag Cloud

{% cache "tag-cloud" %}
  {% let tags = site.tags
    |> items()
    |> sort_by('count', reverse=true)
    |> take(20) %}

  <div class="tag-cloud">
    {% for tag in tags %}
      <a href="{{ tag_url(tag.name) }}" class="tag tag-{{ tag.count }}">
        {{ tag.name }}
      </a>
    {% end %}
  </div>
{% end %}

Cache Invalidation

Version-Based

{% cache "sidebar-" ~ site.nav_version %}
  {# Cache invalidates when nav_version changes #}
  {{ build_sidebar() }}
{% end %}

Content-Based

{% cache "post-meta-" ~ post.id ~ "-" ~ post.updated_at %}
  {# Cache invalidates when post is updated #}
  <div class="post-meta">
    <time>{{ post.date | dateformat('%B %d, %Y') }}</time>
  </div>
{% end %}

Manual Invalidation

Fragment cache is stored in memory and persists only during the build process. To clear it:

During build:

  • Fragment cache is automatically cleared when you restart the build process
  • Usebengal clean --cacheto clear all caches (including bytecode cache)

Note: The fragment cache is separate from the bytecode cache:

  • Fragment cache: In-memory LRU cache for{% cache %}block outputs (cleared on process restart)
  • Bytecode cache: Disk cache at.bengal/cache/kida/for compiled template bytecode (persists across builds)
# Clear all caches (output + bytecode cache)
bengal clean --cache

# Clear only bytecode cache (fragment cache is in-memory)
rm -rf .bengal/cache/kida/

Best Practices

Cache Expensive Operations

{# ✅ Expensive: Cache it #}
{% cache "expensive-calculation" %}
  {% let result = expensive_function(site.pages) %}
  {{ result }}
{% end %}

{# ❌ Cheap: Don't cache #}
{{ page.title }}

Configure TTL Appropriately

Set the global TTL inbengal.yamlbased on your content update frequency:

kida:
  fragment_ttl: 300.0  # 5 minutes - good for frequently changing content
  # fragment_ttl: 3600.0  # 1 hour - good for rarely changing content
{# All fragments use the same TTL from configuration #}
{% cache "recent-posts" %}
  {{ get_recent_posts() }}
{% end %}

{% cache "site-stats" %}
  {{ calculate_stats() }}
{% end %}

Don't Over-Cache

{# ❌ Don't cache simple variables #}
{% cache "title" %}
  {{ page.title }}
{% end %}

{# ✅ Cache expensive operations #}
{% cache "related-posts" %}
  {{ calculate_related_posts(page) }}
{% end %}

Configuration

Configure cache settings inbengal.yaml:

kida:
  fragment_cache_size: 1000  # Max cached fragments (LRU eviction)
  fragment_ttl: 300.0         # Global TTL in seconds for all fragments (5 min)

Cache behavior:

  • fragment_cache_size: Maximum number of cached fragments. When exceeded, least recently used entries are evicted.
  • fragment_ttl: Time-to-live in seconds applied to all cached fragments. Fragments expire after this duration regardless of access.
  • Fragment cache is in-memory only and cleared when the build process ends.

Debugging

Fragment cache statistics:

Fragment cache statistics are available programmatically via the Kida environment'scache_info()method. The fragment cache is in-memory and cleared on each build.

Bytecode cache:

The bytecode cache (compiled template bytecode) is stored on disk:

# Check bytecode cache directory
ls -la .bengal/cache/kida/

# Build with verbose output
bengal build --verbose

Note: Fragment cache (for{% cache %}blocks) and bytecode cache (for compiled templates) are separate systems with different storage locations and purposes.

Complete Example

{% extends "baseof.html" %}

{% let post = page %}

{% block content %}
  <article class="blog-post">
    <header>
      <h1>{{ post.title }}</h1>

      {% cache "post-meta-" ~ post.id %}
        <div class="post-meta">
          <time>{{ post.date | dateformat('%B %d, %Y') }}</time>
          <span>{{ post.reading_time }} min read</span>
        </div>
      {% end %}
    </header>

    <div class="content">
      {{ post.content | safe }}
    </div>

    {% cache "related-posts-" ~ post.id %}
      {% let related = site.pages
        |> where('type', 'blog')
        |> where('tags', post.tags[0] | default(''))
        |> where('id', '!=', post.id)
        |> sort_by('date', reverse=true)
        |> take(5) %}

      {% if related %}
        <aside class="related-posts">
          <h2>Related Posts</h2>
          <ul>
            {% for item in related %}
              <li><a href="{{ item.url }}">{{ item.title }}</a></li>
            {% end %}
          </ul>
        </aside>
      {% end %}
    {% end %}
  </article>
{% endblock %}

{% block sidebar %}
  {% cache "sidebar-nav-" ~ site.nav_version %}
    {{ build_sidebar_nav(site.pages) }}
  {% end %}

  {% cache "tag-cloud" %}
    {{ build_tag_cloud(site.tags) }}
  {% end %}
{% endblock %}