How the Instatic Three-Tier Publishing Pipeline Balances Speed and Dynamic Content
Instatic’s three-tier publishing pipeline separates disk baking, in-memory LRU caching, and dynamic island rendering to serve pre-built HTML from the filesystem while hydrating per-visitor content client-side.
The CoreBunch/Instatic repository implements a hybrid static site architecture that eliminates the traditional trade-off between static performance and dynamic personalization. By routing requests through three distinct layers—disk artefacts, memory caches, and on-demand fragments—the Instatic three-tier publishing pipeline guarantees that static pages never hit the server while still supporting real-time dynamic content.
Architecture Overview
The pipeline processes every page through a progressive fallback system:
- Layer A (Disk Bake): Pre-rendered HTML served directly from the filesystem using atomic deployments.
- Layer B (In-Memory LRU): Cached responses for URLs with query parameters, keyed by canonical request signatures.
- Layer C (Dynamic Islands): Client-side hydration of
<instatic-hole>fragments for per-visitor data.
This separation ensures maximum cache efficiency while preserving the ability to inject personalized content without rebuilding entire pages.
Layer A: Disk Baking and Atomic Deployment
During a full publish operation, the core publishPage function in src/core/publisher/render.ts walks the page tree, collects deduplicated CSS via src/core/publisher/cssCollector.ts, and determines which nodes are static versus dynamic using src/core/publisher/dynamicDetection.ts.
The disk baking layer writes every static page to uploads/published/current/<route>.html using a two-slot symlink swap strategy implemented in server/publish/staticArtefact.ts. This atomic update pattern ensures zero-downtime deployments—readers always see a complete version of the site even during file writes.
Pages containing dynamic nodes receive a static shell with <instatic-hole> placeholders rather than full content, allowing the build to proceed without blocking on per-visitor data.
Layer B: In-Memory LRU Caching
When a request arrives with query parameters that affect rendering, Instatic falls back to an in-memory LRU cache managed by server/publish/renderCache.ts. The cache stores full HTML responses keyed by the tuple (urlPath, canonicalQuery), ensuring that identical parameter combinations reuse rendered output without recomputation.
The cache respects versioning through server/publish/publishState.ts, which maintains a monotonic publishVersion. When a new publish completes, the version bumps automatically, evicting stale entries and preventing cache pollution across deployments.
Layer C: Dynamic Islands for Per-Visitor Content
Pages marked as dynamic during the build phase emit <instatic-hole> tags via src/core/publisher/renderNode.ts rather than final HTML. The client-side runtime in server/publish/holeRuntime.ts—a ~1.1 KB IntersectionObserver-based script—detects these placeholders and lazily fetches fragments from the /_instatic/hole/<nodeId> endpoint defined in server/handlers/cms/hole.ts.
This endpoint re-renders the specific node on-demand, bypassing both disk and memory caches to deliver truly per-visitor content. Because only the dynamic fragment executes server-side, the initial HTML remains lightweight and cacheable.
Rendering Pipeline Implementation
The publishing process begins with the publishPage entry point, which orchestrates the entire flow:
import { publishPage } from "./src/core/publisher/render.ts";
const { html, filename } = await publishPage(pageTree, siteDoc, moduleRegistry);
// html contains the full document; filename maps to uploads/published/current/
During tree traversal, findDynamicNodeIds in src/core/publisher/dynamicDetection.ts classifies nodes:
import { findDynamicNodeIds } from "./src/core/publisher/dynamicDetection.ts";
const dynamicIds = findDynamicNodeIds(pageTree, siteDoc, registry);
if (dynamicIds.has(nodeId)) {
// renderNode emits <instatic-hole> for this node (Layer C)
}
Request Routing Through All Three Layers
The public router in server/publish/publicRouter.ts implements the three-tier resolution strategy:
import { renderPublicResolution } from "./server/publish/publicRouter.ts";
export async function handleRequest(req) {
const response = await renderPublicResolution(req, {
// Router execution flow:
// 1. Attempt disk artefact retrieval (Layer A)
// 2. Fall back to LRU cache for parameterized URLs (Layer B)
// 3. Render dynamic holes on demand (Layer C)
});
return response;
}
This hierarchical check ensures that static content serves instantly from disk, cached variants hit memory, and only personalized fragments trigger server-side rendering.
Summary
- Disk baking in
server/publish/staticArtefact.tsprovides atomic, zero-downtime deployments through symlink swaps, writing static HTML touploads/published/current/<route>.html. - In-memory LRU caching via
server/publish/renderCache.tsaccelerates parameterized requests using versioned cache keys keyed by(urlPath, canonicalQuery), with invalidation handled byserver/publish/publishState.ts. - Dynamic islands using
server/publish/holeRuntime.tsandserver/handlers/cms/hole.tsenable per-visitor content without sacrificing initial page performance, hydrating<instatic-hole>fragments on demand. - Dynamic detection in
src/core/publisher/dynamicDetection.tsdetermines at build time which nodes require runtime rendering versus static baking.
Frequently Asked Questions
How does Instatic handle cache invalidation during new publishes?
The publishVersion counter in server/publish/publishState.ts increments atomically with each deployment. The in-memory LRU cache associates every entry with the current version at insertion time, and lookups automatically filter out entries with mismatched versions. This version-based eviction ensures stale content disappears immediately without requiring manual cache flushes.
What happens if a page contains both static and dynamic content?
During the bake phase in src/core/publisher/render.ts, dynamicDetection.ts analyzes the node tree to identify dynamic subtrees. Static portions render to HTML immediately, while dynamic nodes receive <instatic-hole> placeholders. The final baked file contains the complete static shell, and the client-side runtime only hydrates the specific holes marked as dynamic, keeping the initial payload minimal while preserving SEO-friendly static content.
Where are the baked HTML files stored on disk?
The disk baking layer writes to uploads/published/current/<route>.html using the atomic write implementation in server/publish/staticArtefact.ts. The system maintains two slots and performs a symlink swap to switch traffic atomically from the old build to the new one, ensuring readers never encounter partially written files during the publish process.
Why does the pipeline use an in-memory cache when files already exist on disk?
The disk layer serves only exact URL matches without query parameters. When requests include canonical query strings that affect rendering—such as filtered views or search parameters—server/publish/renderCache.ts stores the computed HTML keyed by both the path and normalized parameters. This prevents re-rendering identical parameterized requests while keeping the disk layer simple and filesystem-backed for the common case of clean URLs.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →