How the Instatic Publisher Generates Static HTML with Atomic Two-Layer Caching
The Instatic publisher combines atomic slot-based file storage with a version-stamped in-memory LRU cache to generate and serve static HTML, ensuring visitors never see partially-written files while supporting dynamic rendering.
Instatic, an open-source static site generator maintained at CoreBunch/Instatic, achieves zero-downtime publishing through a sophisticated two-layer caching architecture. This system separates pre-rendered static artefacts from dynamic page generation, using atomic file operations and publish-version tracking to guarantee cache coherency across the entire request lifecycle.
Overview of the Two-Layer Architecture
The caching system operates through complementary mechanisms defined in server/publish/staticArtefact.ts and server/publish/renderCache.ts. Layer A maintains a dual-slot filesystem structure that enables atomic deployments, while Layer B provides an in-memory LRU cache for dynamic content with automatic invalidation based on a global publish version stored in server/publish/publishState.ts.
Together, these layers ensure that the publicRouter.ts entry point can serve fully-rendered HTML instantly from disk for static routes, while falling back to cached dynamic generation when necessary—all without exposing intermediate states to visitors.
Layer A: Slot-Aware Static Artefacts
The static artefact system uses an A/B slot pattern to guarantee atomic publishes. Instead of overwriting active files, the system writes to an inactive slot and swaps the symlink atomically.
Preparing the Inactive Slot
The prepareInactiveSlot() function in server/publish/staticArtefact.ts identifies which slot (a or b) is currently inactive and wipes it clean while the active slot continues serving traffic. This ensures that visitors cannot access partially-written artefacts during the publishing process.
Atomic Write Operations
When writing content, writeArtefact() creates a temporary file with a *.tmp extension in the target slot directory, then uses the atomic rename(2) system call to move it into place. This ensures that readers never encounter half-written HTML files, as the filesystem guarantees that open(2) calls will see either the old file or the new file, never an intermediate state.
Zero-Downtime Slot Swapping
The swapSlot() function creates a temporary symlink current.tmp pointing to the newly populated slot, then atomically renames it over the current symlink. Because the current symlink always points to a fully-populated slot, visitors experience either the old or new version of the site, never a mixed state.
Reading with Race-Condition Handling
The readArtefact() function follows the current symlink in a single open(2) syscall and implements retry logic to survive transient races—such as when a slot wipe occurs between symlink resolution and file opening. If the artefact is missing or the URL is unsafe, the function returns null, triggering a fallback to the render cache layer.
Layer B: In-Memory LRU Render Cache
For dynamic routes that cannot be pre-baked, Instatic uses an in-memory cache that respects the global publish lifecycle.
Version-Stamped Cache Keys
Cache entries are keyed by a combination of urlPath and queryString joined with a NUL separator to prevent collisions. Each entry records the publishVersion that was current when rendering began, as exposed by publishState.ts. This versioning ensures that cached content from previous publishes never bleeds into new deployments.
Single-Flight Rendering with getOrRender()
The getOrRender() function in server/publish/renderCache.ts implements a single-flight pattern: when a cache miss occurs, concurrent requests for the same URL wait for a single rendering operation rather than triggering duplicate work. The factory function renders the page, and the result is cached only if the publish version has not changed during rendering, preventing stale HTML storage.
Cache Invalidation and Eviction
The peek() method returns cached responses only if the entry's version matches the current global version, promoting the entry in LRU order when accessed. A configurable RENDER_CACHE_MAX_ENTRIES setting caps the map size, evicting the oldest entries when the limit is reached. The getStats() function exposes hits, misses, and current size for observability.
Complete Publishing Workflow
Full Site Publishing
A complete publish orchestrated by server/publish/publishSite.ts follows this atomic workflow:
import { prepareInactiveSlot, writeArtefact, swapSlot } from '@/server/publish/staticArtefact'
async function publishAll(pages: {url: string; html: string}[], uploadsDir: string) {
// 1️⃣ Prepare the inactive slot (`a` or `b`)
const { slot, slotDir } = await prepareInactiveSlot(uploadsDir)
// 2️⃣ Write each page into the inactive slot using atomic temp files
for (const {url, html} of pages) {
await writeArtefact(slotDir, url, html)
}
// 3️⃣ Atomically make the new slot live via symlink swap
await swapSlot(uploadsDir, slot)
}
Incremental Updates
For single-page updates without a full slot swap, updateArtefactInPlace() uses the same temporary-file-plus-rename pattern to modify files in the active slot atomically:
import { updateArtefactInPlace } from '@/server/publish/staticArtefact'
await updateArtefactInPlace('/tmp/uploads', '/blog/my-post', '<html>…</html>')
Dynamic Route Handling
When readArtefact() returns null for missing static files, the system falls back to the render cache:
import { getOrRender, peek } from '@/server/publish/renderCache'
async function handleRequest(urlPath: string, query: string) {
const key = { urlPath, queryString: query }
// Try the fast in-memory path first
const cached = peek(key)
if (cached) return cached
// Miss – render once and cache the result with single-flight protection
return await getOrRender(key, async () => {
const html = await renderPageFromSnapshot(urlPath, query)
return html ? { body: html, headers: {}, status: 200 } : null
})
}
Summary
- Atomic file operations in
server/publish/staticArtefact.tsuse A/B slots andrename(2)to guarantee visitors never see partially-written HTML. - Zero-downtime deployment occurs through symlink swapping, where
currentalways points to a complete slot. - Version-stamped caching in
server/publish/renderCache.tsensures dynamic content respects publish boundaries via the globalpublishVersion. - Single-flight rendering prevents thundering-herd problems by deduplicating concurrent render requests for the same URL.
- Automatic invalidation happens lazily when
peek()detects version mismatches, eliminating the need for explicit cache clearing during deployment.
Frequently Asked Questions
What happens if a visitor requests a page during a slot swap?
Visitors either receive files from the old slot or the new slot, but never a mixture or partially-written content. Because swapSlot() uses an atomic rename(2) operation on the current symlink, filesystem reads are isolated to one complete slot or the other.
How does Instatic prevent serving stale dynamic content after a new publish?
The render cache in server/publish/renderCache.ts stamps each entry with the publishVersion active when rendering started. The peek() function compares this value against the current global version from publishState.ts and returns null for stale entries, forcing a re-render on the next request.
Why does Instatic use two slots instead of overwriting files in place?
Overwriting files in place risks serving incomplete content to visitors during large writes. The two-slot system allows prepareInactiveSlot() to populate an entire site structure atomically, then switch traffic instantly via symlink without risking file corruption or partial states in the active serving directory.
What is the single-flight pattern and why is it important?
The single-flight pattern in getOrRender() ensures that when multiple concurrent requests miss the cache for the same URL, only one rendering operation executes while others wait for the result. This prevents redundant CPU work and database queries during traffic spikes, as implemented in server/publish/renderCache.ts.
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 →