How Instatic's Publish Versioning and Cache Invalidation Work: A Deep Dive into the Three-Layer Pipeline

Instatic uses a monotonic publish version counter combined with an in-process publish lock and version-keyed single-flight memo caches to ensure static artefacts, in-memory render caches, and snapshot helpers stay synchronized across every publish operation.

Instatic, an open-source static site generator, implements a deterministic three-layer publishing pipeline that guarantees content consistency across full-site and per-row publishes. Understanding how Instatic's publish versioning and cache invalidation work is essential for developers extending the platform or debugging cache-related issues. The architecture relies on a global version counter stamped onto every artefact and a serialization mechanism that prevents race conditions during the read-transaction-bake cycle.

The Monotonic Publish Version Counter

The foundation of Instatic's cache coherence lies in a global integer that tracks every successful publish operation. In server/publish/publishState.ts, the module maintains a publishVersion state that increments monotonically using bumpPublishVersion().

// server/publish/publishState.ts
let publishVersion = 0

export function bumpPublishVersion(): number {
  return ++publishVersion        // called after a publish commit
}

export function getPublishVersion(): number {
  return publishVersion
}

Every successful publish—whether full-site, per-row, or unpublish—invokes either bumpPublishVersion() or bumpPublishVersionSerialized(). The latter runs under the publish lock to avoid race conditions during concurrent operations. When baking static artefacts, the system calculates nextPublishVersion = getPublishVersion() + 1 to stamp the forthcoming version onto placeholders, as seen in server/publish/publishSite.ts at lines 199-203.

The In-Process Publish Lock

Publishing spans three distinct phases: reading data, committing a database transaction, and writing disk artefacts. The window between reading the current version and bumping the version must never interleave with another publish, otherwise <instatic-hole> shells could be stamped with stale version numbers.

The withPublishLock() function in server/publish/publishState.ts serializes every publish operation using a promise chain:

// server/publish/publishState.ts
let publishChain = Promise.resolve()

export function withPublishLock<T>(fn: () => Promise<T>): Promise<T> {
  const run = () => fn()
  const result = publishChain.then(run, run)
  publishChain = result.then(() => undefined, () => undefined)
  return result
}

Both publishDraftSite and publishDataRow wrap their core logic with this lock. You can see the implementation in server/publish/publishSite.ts at lines 83-86 and in server/publish/publishRow.ts at lines 50-53, ensuring that only one publish operation mutates global state at any time.

Version-Keyed Single-Flight Memo Caches

Some expensive, version-specific computations—such as generating the site-wide CSS bundle or snapshot memos used by the hole endpoint—must be cached per publish version. The createVersionedSingleFlight() utility constructs a memo that only retains values when the publish version matches the version at load time.

// server/publish/publishState.ts (excerpt)
export function createVersionedSingleFlight<T>(): VersionedSingleFlight<T> {
  let cache: { version: number; value: T } | null = null
  let inFlight: { version: number; promise: Promise<T | null> } | null = null
  // ...
}

When bumpPublishVersion() increments the global counter, any memo created via this function automatically invalidates its cached value because the stored version no longer matches getPublishVersion(). All version-keyed caches register reset callbacks via registerVersionedCacheReset(), and test harnesses can clear the entire state using resetPublishStateForTests().

The Three-Layer Cache Invalidation Flow

Instatic's cache invalidation operates across three distinct layers, each using the publish version to determine freshness:

Layer A: Static Artefacts

When baking HTML, CSS, or JavaScript, Instatic writes to an inactive slot before swapping it active. During this process, the nextPublishVersion is embedded into every <instatic-hole> placeholder via the data-instatic-version attribute. This occurs in server/publish/publishSite.ts where nextPublishVersion = getPublishVersion() + 1 prepares the artefacts for the upcoming version.

Layer B: In-Memory Render Cache

Immediately after the slot swap completes, the system calls bumpPublishVersion() synchronously—without awaiting between the swap and the bump—to invalidate the LRU cache and any version-keyed snapshots. This happens at line 301 in server/publish/publishSite.ts and line 78 in server/publish/publishRow.ts.

Layer C: Hole Runtime

The hole endpoint reads the data-instatic-version attribute from incoming requests and compares it against the current global version via getPublishVersion(). If the versions differ, the fragment is considered stale and re-fetched, ensuring visitors never receive cached content from a previous publish.

Architecture Enforcement

To prevent regressions, the architecture test at src/__tests__/architecture/publish-bumps-cache-version.test.ts enforces that every publish entry point imports and calls the bump functions from the publishState module. This automated gate verifies that no code path can publish without incrementing the version, as documented in lines 31-35 of the test file.

End-to-End Example

The following demonstrates how a full-site publish increments the version and affects subsequent requests:

import { publishDraftSite } from '@/server/publish/publishSite'
import { getPublishVersion } from '@/server/publish/publishState'

// Trigger a full site publish
await publishDraftSite(db, adminUserId, '/tmp/uploads')

// The version is now incremented
console.log('Current publish version:', getPublishVersion()) // → 1

// Subsequent requests that hit a hole placeholder will see
// data-instatic-version="1" and will be considered fresh.

Summary

  • Global version counter: server/publish/publishState.ts maintains a monotonic integer that increments via bumpPublishVersion() after every successful publish.
  • Race-condition prevention: withPublishLock() serializes publish operations using a promise chain, ensuring version consistency across the read-transaction-bake lifecycle.
  • Automatic cache invalidation: Version-keyed single-flight memos created via createVersionedSingleFlight() automatically discard stale data when the publish version changes.
  • Three-layer validation: Static artefacts embed the version in HTML attributes, the in-memory cache invalidates immediately after slot swaps, and the hole runtime validates request versions against the global state.
  • Test enforcement: Architecture tests ensure every publish path bumps the version, preventing silent cache coherency bugs.

Frequently Asked Questions

What triggers a publish version bump in Instatic?

Any successful publish operation—whether publishDraftSite for full-site publishes, publishDataRow for single-row updates, or unpublish operations—calls bumpPublishVersion() or bumpPublishVersionSerialized() in server/publish/publishState.ts. The bump occurs immediately after the atomic slot swap, ensuring the new version number reflects the freshly baked artefacts.

How does Instatic prevent race conditions during concurrent publishes?

The withPublishLock() function in server/publish/publishState.ts implements an in-process promise chain that serializes all publish operations. By wrapping the three-phase publish logic (read → transaction → bake) in this lock, Instatic guarantees that no two publishes can interleave, preventing scenarios where hole placeholders might receive incorrect version stamps.

What happens to cached data when the publish version increments?

All version-keyed caches created via createVersionedSingleFlight() compare their stored version against the global publishVersion on every access. When bumpPublishVersion() increments the counter, these caches immediately return null or recompute their values because the version check fails. Additionally, the in-memory LRU cache invalidates synchronously with the version bump.

How does the hole endpoint verify content freshness?

The hole endpoint extracts the data-instatic-version attribute from incoming requests and compares it to the current value returned by getPublishVersion(). If the request's version differs from the global version, the endpoint treats the fragment as stale and re-fetches the content, ensuring users always receive the latest published data even when browser caches or CDN edge nodes are involved.

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 →