# Instatic Three-Layer Publishing Cache: Static Slots, LRU, and Dynamic Holes

> Explore Instatic's three-layer publishing cache: static slots, LRU, and dynamic holes. Achieve disk speed with real-time personalization. Learn more!

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-30

---

**Instatic uses a three-layer publishing cache—comprising atomic static disk slots, a versioned in-memory LRU cache, and lazy-loaded dynamic holes—to deliver pre-rendered HTML at disk speed while still supporting real-time, per-visitor personalization.**

The CoreBunch/Instatic repository implements a sophisticated caching strategy that bridges the gap between fully static site generation and dynamic web applications. This architecture ensures visitors always receive consistent, instantly available content, even when pages contain user-specific data or personalization logic.

## Layer A: Atomic Static Slots

The first layer of the **Instatic three-layer publishing cache** writes every page as plain HTML to the filesystem using a dual-slot mechanism that guarantees zero-downtime deployments.

In [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts), the system maintains two directories—`a` and `b`—inside `<uploads>/published/`. A symlink named `current` points to whichever slot is actively serving traffic. When publishing occurs, the system writes to the inactive slot, then atomically swaps the symlink.

### Implementation Details

The slot management relies on three core functions:

- **`prepareInactiveSlot`** – Identifies which slot (`a` or `b`) is not currently active and prepares it for writing
- **`writeArtefact`** – Writes HTML files and static assets into the prepared slot
- **`swapSlot`** – Atomically updates the `current` symlink to point to the newly populated slot

```typescript
// server/publish/staticArtefact.ts
import {
  prepareInactiveSlot,
  writeArtefact,
  swapSlot,
} from '@/server/publish/staticArtefact'

// 1️⃣ Prepare the empty slot (the one NOT currently active)
const { slot, slotDir } = await prepareInactiveSlot(uploadsDir)

// 2️⃣ Write each page's HTML into that slot
for (const page of pages) {
  await writeArtefact(slotDir, page.urlPath, page.html)
}

// 3️⃣ Atomically switch the live slot
await swapSlot(uploadsDir, slot)

```

Because the symlink swap is atomic, visitors never encounter partially written files or mixed versions during a publish operation.

## Layer B: Versioned In-Memory LRU Cache

When a request cannot be served from the static disk—such as pages with query parameters or on-the-fly generated routes—the system falls back to an in-memory LRU (Least Recently Used) cache implemented in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts).

This cache stores rendered HTML strings keyed by a composite of `urlPath`, `queryString`, and `publishVersion`. The inclusion of `publishVersion` is critical: every full site publish bumps the global version number, automatically invalidating the entire cache and preventing stale content delivery.

### Cache Invalidation Strategy

The version-aware design eliminates the need to track individual cache entries during deployment. When `publishDraftSite` completes and swaps the static slot, the concurrent version bump in the LRU cache ensures complete consistency between Layer A and Layer B.

```typescript
// server/publish/renderCache.ts
import { renderCache } from '@/server/publish/renderCache'

export async function renderPage(url: string, query: string) {
  const key = `${url}|${query}|${publishVersion}`
  const cached = renderCache.get(key)
  if (cached) return cached

  // Miss → render fresh HTML (includes dynamic-hole placeholders)
  const freshHtml = await renderFullPage(url, query)
  renderCache.set(key, freshHtml)   // store for future requests
  return freshHtml
}

```

Cache hits bypass the rendering pipeline entirely, serving pre-computed HTML directly from memory.

## Layer C: Dynamic Holes for Personalization

The third layer solves the challenge of mixing static caching with truly dynamic, per-request content. In [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts), the system analyzes the rendered page tree to identify nodes that depend on request-specific data—such as user authentication state, personalization, or A/B test variants.

These dynamic sections are replaced with `<instatic-hole data-node-id="…">` placeholders during the publish phase. The initial HTML delivered to the browser contains these lightweight markers, while the actual personalized content is fetched later by a 1 kB client-side IntersectionObserver script.

### Detection and Lazy Loading

The detection algorithm applies four specific rules to determine if a node should be treated as dynamic. Once identified, the placeholder is inserted, and the client-side runtime handles the rest:

- The tiny loader script observes when holes scroll into view
- It fetches the fragment from `/_instatic/hole/<nodeId>`
- The response is injected into the DOM, completing the personalized page

```typescript
// src/core/publisher/dynamicDetection.ts
import { detectDynamicHoles } from '@/src/core/publisher/dynamicDetection'

// After rendering a page tree, run the detector
const treeWithHoles = detectDynamicHoles(renderedTree)

// The returned HTML contains <instatic-hole …> tags
// The client script later fetches /_instatic/hole/<nodeId>
// and replaces the placeholder with the personalized fragment

```

This approach keeps the initial payload extremely small—often just a few bytes per dynamic section—while allowing the majority of the page to benefit from static caching.

## How the Three Layers Interact

A request flows through the **Instatic publishing cache** in a strict priority order:

1. **Static Slot Check** – The router first attempts `readArtefact` to locate `<uploads>/published/current/<path>.html`. If found, the file streams directly to the client via a single system call.
2. **LRU Fallback** – If the static file is missing, the system queries `renderCache` using the composite key including the current publish version. A cache hit returns the stored HTML immediately.
3. **Dynamic Rendering** – On an LRU miss, the page renders fresh. During this process, `detectDynamicHoles` scans the output and replaces request-dependent nodes with `<instatic-hole>` placeholders.
4. **Client Hydration** – The browser receives the HTML (either from the static slot or the LRU cache) containing placeholders. The tiny IntersectionObserver script loads dynamic fragments only when they become visible.

Because the static slot swap is atomic and the LRU cache is version-aware, no visitor ever sees partially published content or outdated cached versions.

## Summary

- **Atomic Static Slots** in [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts) enable zero-downtime publishes using dual slots (`a`/`b`) and a `current` symlink that swaps atomically.
- **Versioned LRU Cache** in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts) provides fast memory-based caching for non-static routes, automatically clearing on each publish via version-tagged keys.
- **Dynamic Holes** in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) isolate request-dependent content into lazy-loaded fragments fetched by a minimal client-side script at `/_instatic/hole/<nodeId>`.
- The layers operate in strict fallback order—disk first, memory second, render third—ensuring optimal performance while maintaining consistency and personalization capabilities.

## Frequently Asked Questions

### What happens to the LRU cache when a new publish occurs?

The LRU cache includes `publishVersion` as part of its lookup key. When a full publish completes, the global version number increments, effectively orphaning all previous cache entries. New requests automatically miss the old cache and populate fresh entries, ensuring no stale content is served after a deployment.

### Why does Instatic use two static slots instead of writing directly to the live directory?

Using two slots (`a` and `b`) with a symlink swap guarantees atomic updates. If the system wrote directly to the live directory, visitors could request files during the write process and receive incomplete or corrupted HTML. By preparing the inactive slot in full before swapping the `current` symlink, Instatic ensures every file served is complete and consistent.

### How do dynamic holes affect SEO and initial page load?

Dynamic holes have minimal impact because the static HTML shell—including meta tags, structured data, and main content—renders immediately from Layer A or B. Search engines see the complete, crawlable page structure, while the `<instatic-hole>` placeholders add only a few bytes each. The actual dynamic content loads via the 1 kB client script when elements enter the viewport, keeping Time to First Byte (TTFB) extremely low.

### What types of content trigger the dynamic hole detection?

The detection logic in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) identifies four categories of request-dependent content: user-specific data (like authentication status), personalized recommendations, A/B test variants, and any content nodes explicitly marked as dynamic by the template logic. These fragments are extracted from the initial render and deferred to the hole-filling mechanism.