How Instatic's Three-Layer Publishing Pipeline Works: Static Bake, In-Memory LRU, and Server Islands
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 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. The write uses an atomic two-slot symlink swap to ensure zero-downtime deployments:
// 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 and stores RenderedPage objects keyed by a composite of urlPath, canonicalQuery, and publishVersion.
Cache invalidation is global and automatic. The publishState.ts module maintains a monotonic publishVersion counter; calling bumpPublishVersion() immediately evicts the entire cache, ensuring stale content never survives a new deploy.
// 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 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 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 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. 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:
- Dynamic Detection: Before rendering,
findDynamicNodeIdsclassifies every node using four heuristics, including loop-body promotion rules. - Static vs. Dynamic Routing:
- If
dynamicNodeIdsis empty → Layer A writes the HTML to disk via atomic swap. - If
dynamicNodeIdscontains entries → Layer B caches the hole-filled HTML, and Layer C handles the dynamic fragments separately.
- If
- 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)
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)
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 handler looks up that specific node ID in the same LRU cache, re-rendering only that subtree if necessary:
// 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, 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, automatically invalidating across deploys viabumpPublishVersion(). - Layer C (Server Islands): Replaces dynamic nodes with
<instatic-hole>placeholders using detection logic insrc/core/publisher/dynamicDetection.ts, then serves fragments on-demand viaserver/publish/holeRuntime.tswhile 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. 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 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 or 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.
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 →