# How Instatic's Three-Layer Publishing Pipeline Works: Static Bake, In-Memory LRU, and Server Islands

> Explore Instatic's three-layer publishing pipeline a static bake, in-memory LRU cache, and server islands This system delivers instant static HTML with dynamic content via lazy loaded fragments

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

---

**Instatic's three-layer publishing pipeline combines a static file system bake (Layer A), an in-memory LRU cache (Layer B), and server-side islands (Layer C) to serve instant static HTML while supporting dynamic content through lazy-loaded fragments.**

The CoreBunch/Instatic repository implements a hybrid rendering strategy that keeps pages lightweight while preserving interactivity. This architecture separates pure static markup from request-dependent data, storing each in optimal locations based on their volatility and access patterns.

## Layer A: Static File System Bake

Layer A handles the coldest data—HTML that never changes between publishes. When you trigger a site publish, `publishDraftSite` in [`server/publish/publishSite.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishSite.ts) iterates over every non-template page and calls `publishPage` to generate the markup.

Pages containing **only** static nodes are written to disk via [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts). The write uses an **atomic two-slot symlink swap** to ensure zero-downtime deployments:

```typescript
// server/publish/staticArtefact.ts
await Bun.write(
  `uploads/published/current/${slug}.html`,
  html,
)

```

This approach guarantees that visitors always see a complete file, never a partial write. Only pages with zero dynamic dependencies make it to this layer; anything requiring request-time data skips the disk entirely and moves to Layer B.

## Layer B: In-Memory LRU Cache

Layer B acts as a hot store for rendered HTML strings, bridging the gap between static files and dynamic content. The cache implementation lives in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts) and stores `RenderedPage` objects keyed by a composite of `urlPath`, `canonicalQuery`, and `publishVersion`.

Cache invalidation is global and automatic. The [`publishState.ts`](https://github.com/CoreBunch/Instatic/blob/main/publishState.ts) module maintains a monotonic `publishVersion` counter; calling `bumpPublishVersion()` immediately evicts the entire cache, ensuring stale content never survives a new deploy.

```typescript
// server/publish/renderCache.ts
const renderCache = new LRUCache<string, RenderedPage>({
  max: 1000, // Configure based on your memory constraints
  ttl: 1000 * 60 * 60 * 24, // Optional TTL
})

```

When [`publicRenderer.ts`](https://github.com/CoreBunch/Instatic/blob/main/publicRenderer.ts) receives an HTTP request, it checks this cache first. A hit returns the HTML instantly; a miss triggers a fresh `publishPage` call, whose result is then stored for subsequent requests.

## Layer C: Server-Side Islands

Layer C solves the dynamic data problem without sacrificing static performance. During rendering, `findDynamicNodeIds` in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) analyzes the page tree to identify nodes that depend on request-time data (cookies, headers, or real-time APIs).

Instead of blocking the render, these nodes are replaced with `<instatic-hole id="{nodeId}">` placeholders. The [`render.ts`](https://github.com/CoreBunch/Instatic/blob/main/render.ts) module tracks these IDs in `acc.holeNodeIds` and emits them into the final HTML. The resulting page is then cached in Layer B—even though it contains holes, the shell itself is static and cacheable.

The client loads a **668-byte IntersectionObserver script** that detects when hole placeholders enter the viewport. It then fetches the actual content from `/_instatic/hole/<nodeId>`, handled by [`server/publish/holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/holeRuntime.ts). This handler reuses the same LRU cache (Layer B) to store individual node fragments, regenerating them on-demand if missing.

## How the Three Layers Integrate

The pipeline orchestrates these layers through a strict decision tree:

1. **Dynamic Detection**: Before rendering, `findDynamicNodeIds` classifies every node using four heuristics, including loop-body promotion rules.
2. **Static vs. Dynamic Routing**: 
   - If `dynamicNodeIds` is empty → Layer A writes the HTML to disk via atomic swap.
   - If `dynamicNodeIds` contains entries → Layer B caches the hole-filled HTML, and Layer C handles the dynamic fragments separately.
3. **Request Flow**: 
   - Browser requests page → Layer B cache lookup → Return shell with holes.
   - Browser requests hole content → Layer C handler checks Layer B for the fragment → Render and return if miss.

This separation means static pages serve directly from the file system (fastest path), while dynamic pages serve from memory (fast) and only hydrate the specific nodes that need data (efficient).

## Practical Implementation Examples

### Baking a Static Page (Layer A)

```typescript
import { publishPage } from '@core/publisher'               // src/core/publisher/render.ts
import { readSiteDocument } from '@core/persistence/site'   // loads the SiteDocument
import { getRegistry } from '@core/registry'                // plugin & module registry

// Load site data
const site = await readSiteDocument('my-site')
const registry = await getRegistry(site)

// Grab the home page
const homePage = site.pages.find(p => p.slug === 'home')!

// Render to static HTML
const html = await publishPage(homePage, site, registry)

// Write to disk (Layer A)
await Bun.write(
  `uploads/published/current/home.html`,
  html,
)

```

If `publishPage` returns HTML with no dynamic holes, this file serves directly via Nginx or Bun's file server on subsequent requests, bypassing the Node.js runtime entirely.

### Serving Dynamic Pages with LRU (Layer B)

```typescript
import { renderCache } from '@/server/publish/renderCache'
import { renderPublishedSnapshot } from '@/server/publish/publicRenderer'

export async function handleRequest(req: Request) {
  const urlPath = new URL(req.url).pathname

  // Layer B: Check in-memory cache
  let cached = renderCache.get(urlPath)
  if (!cached) {
    // Cache miss: regenerate via publishPage
    cached = await renderPublishedSnapshot(urlPath)
    renderCache.set(urlPath, cached)
  }

  // Return shell with <instatic-hole> placeholders
  return new Response(cached.html, {
    headers: { 'Content-Type': 'text/html' },
  })
}

```

### Handling Dynamic Fragments (Layer C)

When the browser encounters `<instatic-hole id="42">`, it fetches `/_instatic/hole/42`. The [`holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/holeRuntime.ts) handler looks up that specific node ID in the same LRU cache, re-rendering only that subtree if necessary:

```typescript
// server/publish/holeRuntime.ts (simplified)
const fragment = renderCache.get(`hole:${nodeId}`) 
  ?? await renderNode(nodeId, config)

```

This ensures dynamic content benefits from the same caching strategy as static shells, while keeping initial page weights minimal.

## Summary

- **Layer A (Static Bake)**: Writes fully static HTML to disk using atomic symlink swaps in [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts), ensuring zero-downtime deployments for pages without dynamic dependencies.
- **Layer B (In-Memory LRU)**: Caches rendered HTML strings keyed by URL, query, and publish version in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts), automatically invalidating across deploys via `bumpPublishVersion()`.
- **Layer C (Server Islands)**: Replaces dynamic nodes with `<instatic-hole>` placeholders using detection logic in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts), then serves fragments on-demand via [`server/publish/holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/holeRuntime.ts) while reusing the Layer B cache.
- The pipeline optimizes for the common case (static content) while providing an escape hatch for dynamic data without sacrificing cacheability or initial load performance.

## Frequently Asked Questions

### How does Instatic handle cache invalidation when content changes?

Instatic uses a global version counter stored in [`server/publish/publishState.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishState.ts). Each publish run calls `bumpPublishVersion()`, which updates the key used in Layer B's LRU cache. Since the cache key includes `publishVersion`, existing entries become unreachable and are evicted naturally by the LRU, ensuring visitors never see stale content after a new deploy.

### Can I disable the server islands feature and render everything statically?

Yes. If `findDynamicNodeIds` in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) returns an empty set for every page, the pipeline writes all HTML to disk via Layer A and never emits `<instatic-hole>` placeholders. To force this behavior, ensure no nodes depend on request-time data (cookies, headers, or dynamic imports) and avoid using loop-body promotion rules that trigger dynamic classification.

### What happens if the LRU cache is full when a dynamic request arrives?

The LRU cache evicts the least recently used entries based on its configured `max` size. If a request arrives for a page or hole fragment not currently in the cache, [`publicRenderer.ts`](https://github.com/CoreBunch/Instatic/blob/main/publicRenderer.ts) or [`holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/holeRuntime.ts) regenerates the content by calling `publishPage` or re-rendering the specific node, then stores the fresh result back in the cache. This miss penalty occurs only for the first request after eviction or version bump.

### How large are the hole fragments compared to full pages?

Hole fragments contain only the HTML for the specific dynamic node plus its scoped CSS, typically measured in kilobytes rather than the tens or hundreds of kilobytes for a full page shell. The client-side loader is approximately **668 bytes**, making the overhead negligible compared to hydrating an entire JavaScript framework on the client.