How Instatic Performs Static Site Generation: Zero-Downtime Deployment with Atomic Slot Swapping
Instatic performs static site generation through a three-phase pipeline that builds assets outside the database, persists metadata in a single atomic transaction, and deploys to a dual-slot directory structure with atomic symlink swapping to guarantee zero downtime.
Instatic, an open-source publishing platform maintained by CoreBunch, transforms in-memory draft sites into fully static exports through a sophisticated multi-layered architecture. The static site generation process separates heavy build operations from database transactions and uses atomic file system operations to ensure visitors never see partially deployed content.
The Three-Layer Architecture
The publishing pipeline is organized into three distinct layers that handle different concerns:
- Layer A – Slot-aware Artefact IO: Writes HTML, CSS, and runtime JavaScript into a two-slot directory structure and atomically swaps the
currentsymlink. Located inserver/publish/staticArtefact.ts. - Layer B – In-memory Render Cache: Manages the publish version to invalidate stale snapshots. Implemented in
server/publish/publishState.ts. - Layer C – Dynamic "Hole" Fragments: Handles request-dependent nodes by baking static shells with placeholders that the browser hydrates at runtime. Found in
src/core/publisher/dynamicDetection.ts.
Phase 1: Building Assets Outside the Database
Before touching the database, Instatic performs all heavy computation that might take seconds or minutes. This prevents blocking concurrent drafts or autosaves.
The process begins in server/publish/publishSite.ts (lines 93-110):
const site = await getDraftSiteDocument(db) // read the draft
const runtime = normalizeSiteRuntimeConfig(site.runtime) // runtime config
const dependencyCache = runtime.dependencyLock.packages ... // install npm deps
const packageImportmap = await buildRuntimePackageImportmap(...)
During this phase, Instatic executes three major build operations:
- Runtime scripts:
buildSiteRuntimeScriptscompiles each page’s client-side bundle into/_instatic/assets/<versionId>/…. - CSS bundle:
buildPublishedSiteCssBundlecreates three core CSS files (reset, framework, style) plus per-page user CSS. - Import-map: A JSON file at
/_instatic/importmap.jsonlets browsers resolve bare imports likeimport "three".
Phase 2: Atomic Metadata Persistence
Once assets are built, Instatic writes a snapshot of the site state in a single, short database transaction. This is the only DB-touching part of the publish flow.
From server/publish/publishSite.ts (lines 71-78):
await persistSitePublish(db, {
siteSnapshotId,
site: publishedSite,
serializedImportmap,
pages: pageWrites,
publishedByUserId: adminUserId,
})
The persistSitePublish function writes the site snapshot, generated CSS/JS metadata, and per-page version numbers into the site_publish tables. This atomic operation ensures that the database state is consistent with the filesystem state that follows.
Phase 3: Zero-Downtime Static Deployment (Layer A)
Layer A implements the core static site generation logic using a two-slot system that guarantees atomic deployments.
Preparing the Inactive Slot
The system maintains two slots, a and b, where only one is active at a time. The prepareInactiveSlot function in server/publish/staticArtefact.ts (lines 99-111) clears the inactive slot before writing new artefacts:
export async function prepareInactiveSlot(
uploadsDir: string,
): Promise<{ slot: Slot; slotDir: string }> {
const slot = await getInactiveSlot(uploadsDir) // 'a' or 'b'
const dir = getSlotDir(uploadsDir, slot)
await mkdir(getPublishedDir(uploadsDir), { recursive: true })
await rm(dir, { recursive: true, force: true }) // wipe old artefacts
await mkdir(dir, { recursive: true }) // fresh empty dir
return { slot, slotDir: dir }
}
Atomic File Writes
Each file is written using a temporary file followed by an atomic rename operation. The writeArtefact function in server/publish/staticArtefact.ts (lines 40-50) ensures readers never see partially written content:
export async function writeArtefact(
slotDir: string,
urlPath: string,
html: string,
) {
const finalPath = resolveArtefactPath(slotDir, urlPath)
const tmpPath = `${finalPath}.tmp`
await mkdir(dirname(finalPath), { recursive: true })
await writeFile(tmpPath, html, 'utf-8')
await rename(tmpPath, finalPath) // atomic replace
}
During the page loop, the system collects assets in a Map<string,Uint8Array> called assetsByPath to deduplicate files when multiple pages reference the same content-hashed resource.
Rendering and Pipeline Processing
For each page, the system renders the HTML and applies post-processing before writing:
// Inside the per-page loop from publishSite.ts (lines 64-73)
const rendered = await renderPublishedSnapshot(snapshot, { db, url: syntheticUrl, publishVersion })
const html = await applyPublishedHtmlPipeline(rendered, db)
await writeArtefact(slotDir, urlPath, html)
collectCssFiles(rendered.cssBundle) // add page-specific CSS
The applyPublishedHtmlPipeline function (from server/publish/publishedHtmlPipeline.ts) injects plugin assets, stamps form tokens, and runs the publish.html filter hook before the final HTML is atomically written.
Handling the 404 Page
The 404 page (/404) is baked before any other page. This guarantees that a fallback error page exists even if a user-created slug /404 overwrites it later.
The Atomic Slot Swap
Once all artefacts are written to the inactive slot, the system performs the atomic swap. The swapSlot function in server/publish/staticArtefact.ts (lines 64-81) updates the current symlink:
export async function swapSlot(uploadsDir: string, targetSlot: Slot) {
const publishDir = getPublishedDir(uploadsDir)
const currentPath = getCurrentSymlinkPath(uploadsDir)
const tmpPath = join(publishDir, 'current.tmp')
await mkdir(publishDir, { recursive: true })
await rm(tmpPath, { force: true }) // remove stale tmp
await symlink(targetSlot, tmpPath) // create new symlink
await rename(tmpPath, currentPath) // atomic replace
}
The current symlink points to the active slot (a or b). By creating current.tmp and atomically renaming it over current, visitors always resolve to a complete slot with no window where the path is missing or partially written.
Layer B: Cache Invalidation and Version Bumping
Immediately after the slot swap, the system increments the global publish version to invalidate in-memory render caches. From server/publish/publishSite.ts (lines 96-101):
bumpPublishVersion()
This version bump signals the live renderer to discard stale snapshots. Subsequent requests either serve the freshly-baked static artefacts from Layer A or re-render dynamic holes using the new version if the page contains request-dependent nodes.
Layer C: Dynamic Holes for Request-Dependent Content
Not all content can be fully static. When a page contains nodes that depend on the request (such as search queries), the publisher emits an <instatic-hole> placeholder instead of baking the dynamic content.
The shell HTML contains:
<instatic-hole data-instatic-version="…"></instatic-hole>
The client-side runtime (from src/core/publisher/holeRuntime.ts) lazily fetches the fragment from /_instatic/hole/<nodeId> using the current publish version. Because the static shell contains the correct version stamp (added during bakePublishedDataRowArtefacts), the fragment is accepted only if it matches the live version, preventing stale content from rendering.
Serving Static Content to Visitors
When a visitor requests a page, the readArtefact function in server/publish/staticArtefact.ts (lines 22-38) reads through the current symlink:
export async function readArtefact(uploadsDir: string, urlPath: string) {
const diskRelPath = computeDiskRelPath(urlPath)
const filePath = join(getPublishedDir(uploadsDir), 'current', diskRelPath)
// retry loop handles transient ENOENT / ENOTDIR / EINVAL during a swap
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await readFile(filePath, 'utf-8')
} catch (err) {
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EINVAL') return null
if (attempt < 4) await new Promise(r => setImmediate(r))
}
}
return null
}
The OS resolves the current symlink atomically once per request, guaranteeing that the visitor sees either the old version or the new version, never a half-baked mix. A retry loop handles transient errors that might occur during the milliseconds of a slot swap.
Practical Implementation Example
To trigger a full static site generation in your own Instatic instance:
import { publishDraftSite } from './server/publish/publishSite'
// In an admin endpoint (e.g. POST /admin/publish)
async function handlePublish(req, res) {
try {
// `uploadsDir` points to the directory where static artefacts are stored
const result = await publishDraftSite(req.db, req.user.id, '/var/www/my-site/uploads')
res.json({ ok: true, pagesPublished: result.publishedPages })
} catch (e) {
console.error('[admin] publish failed:', e)
res.status(500).json({ ok: false, error: e.message })
}
}
Calling publishDraftSite executes the complete pipeline: building assets, persisting metadata, writing to a new slot, performing the atomic swap, and bumping the version—all without downtime.
Summary
- Instatic uses a three-layer architecture to separate artefact IO, cache management, and dynamic content handling.
- Phase 1 builds heavy assets (JavaScript bundles, CSS, import maps) outside the database to avoid blocking concurrent operations.
- Phase 2 persists site metadata in a single atomic database transaction using
persistSitePublish. - Phase 3 writes static files to an inactive slot using atomic
rename(2)operations, then swaps thecurrentsymlink to point to the new slot. - Layer C supports dynamic content through
<instatic-hole>placeholders that hydrate after the static shell loads. - The
readArtefactfunction includes retry logic to handle transient filesystem errors during slot swaps, ensuring visitors always see consistent content.
Frequently Asked Questions
What is the dual-slot architecture in Instatic?
The dual-slot architecture maintains two directories (a and b) where only one is active at any time. New static site generations write to the inactive slot, and once complete, the current symlink is atomically switched to point to the new slot. This ensures zero downtime because visitors always see a complete, consistent version of the site, never a partially written one.
How does Instatic handle dynamic content in a static site?
Instatic detects request-dependent nodes during the publishing phase and replaces them with <instatic-hole> placeholders in the static HTML. The browser then fetches the dynamic fragments from /_instatic/hole/<nodeId> at runtime. Each fragment request includes the publish version to ensure the dynamic content matches the static shell's generation.
Why does Instatic build assets outside the database transaction?
Heavy computations like esbuild bundling and npm package installation can take seconds or minutes. Performing these outside the database transaction prevents long-running locks that would block concurrent drafts, autosaves, or other database operations. Only the lightweight metadata persistence happens inside the transaction.
How does Instatic ensure zero-downtime deployments?
Instatic achieves zero downtime through atomic filesystem operations. Individual files are written to temporary paths and renamed into place, ensuring atomicity at the file level. At the site level, the entire deployment is written to an inactive slot before the current symlink is atomically switched via rename(2), guaranteeing that visitors never encounter partial or corrupted deployments.
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 →