How Instatic's Three-Layer Publishing Pipeline Works: From Static Bake to Dynamic Islands

Instatic's three-layer publishing pipeline separates static generation, dynamic caching, and client-side hydration to deliver static-first performance while supporting request-dependent content through atomic disk writes, versioned in-memory LRU caching, and runtime dynamic islands.

Instatic, an open-source static site generator maintained at CoreBunch/Instatic, renders page trees to complete HTML documents through a sophisticated three-layer architecture. This pipeline maximizes cacheability by isolating dynamic fragments, ensuring visitors receive pre-rendered content instantly while request-dependent elements hydrate on demand.

The Three Layers: Disk, Memory, and Runtime

The pipeline organizes publishing into three distinct layers, each handling a specific phase of the content lifecycle:

Layer Function Primary File
Layer A – Disk Bake Renders the page tree and writes HTML to disk via atomic symlink swap server/publish/staticArtefact.ts
Layer B – In-Memory LRU Caches dynamic pages keyed by canonical query strings server/publish/renderCache.ts
Layer C – Dynamic-Island Runtime Hydrates request-dependent fragments client-side server/publish/holeRuntime.ts

Layer A: Disk Bake (Static Generation)

Layer A handles the initial static generation. The publishPage function in src/core/publisher/render.ts walks the node tree recursively (renderNode.ts), producing HTML strings for each page. The system writes these files to uploads/published/current/<route>.html using an atomic two-slot symlink swap implemented in staticArtefact.ts. This ensures readers never encounter half-written files during deployment.

Static shells may contain <instatic-hole> placeholders where dynamic content will eventually render, but the disk version represents the fastest possible delivery path.

Layer B: In-Memory LRU (Dynamic Page Cache)

Layer B manages dynamic pages—those containing request-dependent nodes—in a versioned least-recently-used cache. The cache keys combine urlPath with a canonicalQuery normalized by canonicalRenderQuery(), which strips all non-loop pagination parameters to maximize cache hits.

When bumpPublishVersion() increments the global publishVersion after a new deployment, Layer B entries expire lazily, preventing stale content without invalidating the entire cache. This single-flight render cache lives in server/publish/renderCache.ts.

Layer C: Dynamic-Island Runtime (Client Hydration)

Layer C activates in the browser. During the Layer A bake, nodes classified as request-dependent are replaced with <instatic-hole> placeholders containing a ~1KB IntersectionObserver script from holeRuntime.ts. When these elements enter the viewport, the client fetches fragments from /_instatic/hole/<nodeId>, and the server re-renders just that subtree using the same publishPage logic, optionally bypassing Layer B for per-visitor personalization.

How the Layers Interact During Publishing

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

  1. Publish Initiation: publishDraftSite writes a new SiteDocument snapshot server-side.
  2. Page Rendering: publishPage performs a full render, using dynamicDetection.ts to identify nodes that become holes.
  3. Post-Processing: The publishedHtmlPipeline.ts applies plugin filters, generates CSP headers, and injects runtime assets.
  4. Atomic Write: The HTML moves through staticArtefact.ts, which executes the two-slot symlink swap to disk.
  5. Cache Versioning: bumpPublishVersion() increments the global version, causing Layer B to expire old entries.

When a visitor requests a URL:

  • The router normalizes the query with canonicalRenderQuery to generate the Layer B cache key.
  • If the request lacks loop pagination parameters, the router serves the disk file directly (Layer A fast-path).
  • Otherwise, it checks 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.
  • Any <instatic-hole> tags hydrate via Layer C, fetching subtrees from the server as the user scrolls.

Dynamic Node Detection

The findDynamicNodeIds function in src/core/publisher/dynamicDetection.ts drives Layers A and C by classifying nodes through four rules (plus a promotion rule):

  1. Explicit Dynamic Flag: Module flagged dynamic: true.
  2. Dynamic Bindings: Node has a dynamicBindings source reading request parameters.
  3. Request-Dependent Loops: Loop source declared requestDependent or perVisitor.
  4. Nested Dynamic Components: Visual-Component reference whose definition contains any dynamic node.

Rule 3.5 (Promotion): If a static loop contains a dynamic child, the entire loop promotes to a single hole, preventing duplicate placeholders and maintaining cache efficiency.

Code Examples

Rendering a Page Programmatically (Layer A Entry Point)

Use publishPage to render a page tree to HTML:

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

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

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

Triggering a Full Site Publish (Layer A + B)

Call the admin API to execute the complete pipeline:

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

The handler in server/publish/publishSite.ts writes the snapshot, bakes every page, swaps the active slot, and calls bumpPublishVersion() to clear Layer B.

Accessing the Dynamic Page Cache (Layer B)

Retrieve or render dynamic pages through the LRU cache:

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

// First request – cache miss, renders and stores
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 return instantly from memory
console.log(cached.body);

Hydrating Dynamic Islands on the Client (Layer C)

The runtime automatically injects this IntersectionObserver logic on pages containing holes:

// ~1KB IIFE shipped to browser from 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'));

Key Implementation Files

File Responsibility
src/core/publisher/render.ts Top-level publishPage orchestration (Layer A)
src/core/publisher/renderNode.ts Recursive walker; inserts <instatic-hole> (Layer C)
src/core/publisher/dynamicDetection.ts Single-pass dynamic-node classifier
src/core/publisher/cssCollector.ts Dedupes and sanitizes per-module CSS
server/publish/staticArtefact.ts Atomic two-slot symlink swap; disk writes (Layer A)
server/publish/renderCache.ts Versioned in-memory LRU (Layer B)
server/publish/publishState.ts Global publishVersion handling
server/publish/holeRuntime.ts Client-side hydration script (Layer C)
server/publish/publicRouter.ts Request routing: Layer A → Layer B → live render
server/publish/publishedHtmlPipeline.ts Post-processing: DOMPurify, CSP injection

Summary

  • Layer A generates static HTML files atomically via symlink swap, ensuring zero-downtime deployments.
  • Layer B caches dynamic pages in a versioned LRU keyed by canonical query strings, automatically expiring when publishVersion increments.
  • Layer C renders request-dependent content client-side through <instatic-hole> elements fetched on intersection.
  • Dynamic detection in dynamicDetection.ts determines which nodes become holes, promoting entire loops when necessary to maintain performance.
  • All assets receive versioned hashes with Cache-Control: immutable headers for permanent browser caching.

Frequently Asked Questions

How does Instatic prevent visitors from seeing half-written files during publishing?

Instatic uses an atomic two-slot symlink swap implemented in server/publish/staticArtefact.ts. The system writes new files to a staging directory, then swaps a symlink pointer to the new location instantaneously. Readers always access complete files through the symlink, never encountering partial writes.

What causes the Layer B cache to invalidate?

The global publishVersion counter triggers Layer B invalidation. When bumpPublishVersion() increments this value (called after successful disk writes), cache entries in renderCache.ts expire lazily. The version check occurs on each lookup, ensuring stale content never serves without requiring explicit cache clearing.

Can dynamic islands bypass the Layer B cache entirely?

Yes. When the Layer C runtime fetches fragments from /_instatic/hole/<nodeId>, the server can optionally bypass the Layer B LRU for per-visitor fragments. This allows personalized content to render fresh for each user while still leveraging the static shell from Layer A for the surrounding page structure.

How does the pipeline handle CSS and JavaScript assets?

During Layer A baking, siteCssBundle.ts assembles four hashed CSS bundles (reset, framework, style, userStyles), deduping module CSS via CssCollector. JavaScript accumulates in renderAccumulators.jsMap during render.ts execution. All assets receive immutable cache headers, allowing browsers to cache them indefinitely across publishes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →