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

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 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 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 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, 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. The dynamic detection system in src/core/publisher/dynamicDetection.ts decides which nodes become holes based on four classification rules.

  3. Post-processing: The HTML flows through 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 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 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:

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.

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

Trigger a complete site publish via the admin API:

// 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 via 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:

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 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:

// ~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, serving as the immutable foundation for static content.

  • Layer B (In-Memory LRU) caches dynamic pages in 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.

  • Dynamic detection in 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

The two-slot symlink swap is an atomic deployment mechanism in 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. 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, 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: 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.

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 →