# How the Instatic Three-Layer Publishing Pipeline Works: Architecture and Implementation

> Learn how the Instatic three-layer publishing pipeline works. Discover its architecture and implementation for maximum cacheability and dynamic content support.

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

---

**Instatic renders static-first HTML documents through a three-layer pipeline that separates disk-baked pages from in-memory dynamic caches and client-side hydration islands, enabling maximum cacheability while supporting request-dependent content.**

The Instatic three-layer publishing pipeline powers the CoreBunch/Instatic static site generator by architecturally separating immutable content from dynamic fragments. This design allows the system to serve pre-baked HTML files for static routes while gracefully handling personalized or request-dependent content through isolated runtime layers.

## Layer A: Disk Bake

Layer A handles the **static-first foundation** of the pipeline. When a publish is triggered, the system walks the entire page tree, renders each node, and writes the resulting HTML to disk.

The write operation in [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts) uses an **atomic two-slot symlink swap** to ensure readers never encounter half-written files. Files are written to `uploads/published/current/<route>.html` using a dual-directory strategy where the active symlink points to the complete slot while the new content writes to the alternate slot. Once the write finishes, the symlink swaps atomically.

For pages containing dynamic content, Layer A writes a **static shell** with placeholders rather than fully rendered HTML. This allows the subsequent layers to inject request-specific content without invalidating the disk cache.

## Layer B: In-Memory LRU Cache

Layer B provides a **versioned in-memory cache** for dynamic pages that cannot be served purely from disk. The implementation in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts) maintains an LRU (Least Recently Used) map keyed by `(urlPath, canonicalQuery)`.

The cache key normalization occurs through `canonicalRenderQuery()`, which strips all non-loop pagination parameters to maximize cache hits. For example, `?loop_posts_page=2` becomes part of the cache key, but visitor-specific tracking parameters do not.

Cache invalidation happens lazily via a global `publishVersion` counter. When `bumpPublishVersion()` is called after a successful publish, all existing Layer B entries become stale and are evicted on next access. This eliminates the need for complex cache warming or batch invalidation logic.

## Layer C: Dynamic-Island Runtime

Layer C handles **request-dependent fragments** through a dynamic-island architecture. During the Layer A bake, nodes classified as dynamic are replaced with an `<instatic-hole>` placeholder containing a ~1KB IntersectionObserver script.

The client-side runtime in [`server/publish/holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/holeRuntime.ts) observes these holes and fetches their content from `/_instatic/hole/<nodeId>` when the element enters the viewport. The server re-renders just that subtree using the same `publishPage` logic from [`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts), optionally bypassing Layer B for per-visitor fragments like personalized greetings or A/B test variants.

This approach isolates dynamic execution to specific DOM nodes while keeping the surrounding document static and cacheable.

## How the Layers Interact

The publishing workflow orchestrates all three layers through a coordinated sequence:

1. **Publish initiation**: `publishDraftSite` writes a new `SiteDocument` snapshot, then iterates every page in the tree.

2. **Page rendering**: For each page, `publishPage` performs a full recursive walk via [`renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/renderNode.ts). The **dynamic detection** system in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) decides which nodes become holes based on four classification rules.

3. **Post-processing**: The HTML flows through [`publishedHtmlPipeline.ts`](https://github.com/CoreBunch/Instatic/blob/main/publishedHtmlPipeline.ts) for DOMPurify sanitization, CSP generation, and runtime asset injection.

4. **Atomic deployment**: The processed document writes to disk via Layer A's two-slot symlink swap.

5. **Cache invalidation**: After deployment, `bumpPublishVersion()` increments the version, causing Layer B entries to expire on next request.

When a visitor requests a URL, the router in [`server/publish/publicRouter.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publicRouter.ts) chains the layers:

- If the request has no loop pagination parameters, serve the **disk file directly** (Layer A fast-path).
- Otherwise, check the **Layer B LRU**. A hit returns cached HTML instantly; a miss triggers a **single-flight render** that calls `publishPage`, caches the result, and returns it.
- The browser receives HTML containing `<instatic-hole>` tags where applicable. The Layer C runtime hydrates these islands by fetching fragments from the server.

## Dynamic Node Detection

The `findDynamicNodeIds` function in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) drives the boundary between Layers A and C. It walks the node tree once and applies four classification rules (plus a promotion rule) to determine if a node is request-dependent:

1. **Module flag**: The module is explicitly marked `dynamic: true`.

2. **Dynamic bindings**: The node has a `dynamicBindings` source that reads request parameters.

3. **Loop dependency**: The loop source is declared `requestDependent` or `perVisitor`.

4. **Visual component recursion**: The visual-component reference contains any dynamic node in its definition.

**Rule 3.5 (Promotion)**: If a static loop contains a dynamic child, the entire loop is promoted to a single hole to avoid duplicate placeholder injection and reduce client-side fetch overhead.

## Code Examples

### Rendering a Page Programmatically (Layer A Entry Point)

Use `publishPage` from the core publisher to render a page tree to HTML:

```typescript
import { publishPage } from '@/core/publisher/render';
import { site, registry } from '@/server/context';

// `page` is a NodeTree<PageNode> obtained from the draft site
const { html, filename } = await publishPage(page, site, registry);

// `html` contains the full document (or a static shell with <instatic-hole>)
console.log('Published to:', filename);

```

This corresponds to the entry point in [`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts).

### Triggering a Full Site Publish (Layer A + B Integration)

Trigger a complete site publish via the admin API:

```typescript
// POST /admin/api/cms/publish/site
await fetch('/admin/api/cms/publish/site', { method: 'POST' });

```

The handler in [`server/publish/publishSite.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishSite.ts) writes the snapshot, bakes every page, swaps the active slot via [`staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/staticArtefact.ts), and calls `bumpPublishVersion()` to clear the Layer B cache.

### Accessing the Dynamic Page Cache (Layer B)

Manually interact with the render cache for dynamic routes:

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

// First request – cache miss, renders and stores result
const cached = await renderCache.getOrRender(
  { urlPath: '/blog', queryString: '?loop_posts_page=2' },
  async () => {
    const { html } = await publishPage(blogPage, site, registry);
    return { body: html, headers: {}, status: 200 };
  },
);

// Subsequent requests with identical canonical keys hit the LRU instantly
console.log(cached.body);

```

The implementation in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts) handles single-flight rendering to prevent thundering-herd issues when cache entries expire.

### Client-Side Hole Hydration (Layer C Runtime)

The browser automatically receives this runtime for pages containing dynamic islands:

```javascript
// ~1KB IIFE from server/publish/holeRuntime.ts
new IntersectionObserver((entries) => {
  entries.forEach(async (e) => {
    if (e.isIntersecting) {
      const nodeId = e.target.dataset.instaticHoleId;
      const version = e.target.dataset.instaticVersion;
      const url = `/_instatic/hole/${nodeId}?v=${version}&u=${location.pathname}`;
      const res = await fetch(url);
      e.target.replaceWith(await res.text());
    }
  });
}).observe(document.querySelectorAll('instatic-hole'));

```

The server endpoint re-renders the specific subtree identified by `nodeId` using the same `publishPage` logic, ensuring consistent rendering between static and dynamic phases.

## Summary

- **Layer A (Disk Bake)** writes atomic HTML files using a two-slot symlink swap in [`staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/staticArtefact.ts), serving as the immutable foundation for static content.

- **Layer B (In-Memory LRU)** caches dynamic pages in [`renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/renderCache.ts) using canonical query keys and version-based invalidation via `publishVersion`.

- **Layer C (Dynamic Islands)** isolates request-dependent content in `<instatic-hole>` placeholders hydrated by an IntersectionObserver runtime from [`holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/holeRuntime.ts).

- **Dynamic detection** in [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/dynamicDetection.ts) determines which nodes become islands based on module flags, bindings, loop dependencies, and recursive component analysis.

- **Single-flight rendering** prevents cache stampedes when Layer B entries expire, ensuring predictable performance under load.

## Frequently Asked Questions

### What is the two-slot symlink swap in Instatic?

The two-slot symlink swap is an atomic deployment mechanism in [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts) that prevents visitors from seeing partially written files. The system writes new HTML to an inactive slot, then atomically swaps the symlink to point to the new slot only after the write completes. This ensures zero-downtime deployments and eliminates the risk of serving corrupted HTML during the publish process.

### How does Layer B cache invalidation work?

Layer B uses a global `publishVersion` counter managed in [`server/publish/publishState.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishState.ts). When `bumpPublishVersion()` is called after a successful publish, existing cache entries are not immediately deleted but marked as stale. On the next request for that entry, the system detects the version mismatch and re-renders the page. This lazy invalidation strategy avoids expensive cache-clearing operations while guaranteeing fresh content.

### When should content be marked as dynamic in Instatic?

Content should be marked dynamic when it depends on request-specific data that varies between visitors or sessions. According to [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts), this includes modules explicitly flagged with `dynamic: true`, nodes reading from `dynamicBindings` sources, loops declared `requestDependent` or `perVisitor`, and visual components containing dynamic descendants. Static content that is identical for all visitors should remain undynamic to maximize Layer A and Layer B cache efficiency.

### What happens if a static loop contains a dynamic child node?

Instatic applies **promotion rule 3.5** in [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/dynamicDetection.ts): if a static loop contains a dynamic child, the entire loop is promoted to a single dynamic island (hole) rather than creating individual holes for each iteration. This prevents the generation of numerous `<instatic-hole>` placeholders that would require separate network requests, instead allowing the entire loop to be rendered as one fragment when the user scrolls it into view.