How Instatic's Two-Slot Symlink Swap Enables Atomic Publishing

Instatic guarantees atomic page publishing by maintaining two directory slots (a and b) and using an atomic rename(2) operation to flip a current symlink between them, ensuring visitors never observe partially written files.

The Instatic static site generator solves the half-written file problem through a robust two-slot symlink swap mechanism implemented in server/publish/staticArtefact.ts. This design leverages POSIX atomicity guarantees to ensure every deployment is an all-or-nothing operation, completely isolating readers from the write process.

The Two-Slot Directory Model

Instatic’s publishing system relies on a three-part directory structure under uploads/published/:

Directory Purpose
uploads/published/a Holds a complete set of static artefacts for one publish version.
uploads/published/b Holds a complete set of static artefacts for the next publish version.
uploads/published/current A symlink that points to either a or b; all runtime reads resolve through this path.

As noted in the source comment at line 8 of server/publish/staticArtefact.ts, the current symlink acts as the "visitor router" that the kernel resolves on every open(2) call. This ensures each HTTP request sees a consistent snapshot of either the old or new version, never a mixture.

Step-by-Step Atomic Publishing Flow

1. Determine the Inactive Slot

The system first identifies which slot is not currently active. The getInactiveSlot() function (line 178 in staticArtefact.ts) inspects the current symlink and returns 'a' or 'b' depending on which directory is not being served.

2. Write to the Inactive Slot

All newly generated static files are copied into the determined inactive slot (e.g., uploads/published/a). This step never touches the active slot, allowing readers to continue accessing the current version without interference.

Before flipping traffic, the code creates a temporary symlink using symlink(targetSlot, tmpPath) (line 286), resulting in a current.tmp file that points to the newly populated slot.

4. Execute the Atomic Swap

The critical operation occurs with rename(tmpPath, currentPath) (described at line 258). On POSIX systems, renaming a symlink is atomic—a single kernel operation that either succeeds completely or fails without side effects. This instantly directs all new requests to the fresh slot while any in-flight requests continue reading from the old directory handle.

5. Preserve the Old Slot

After the swap completes, the previous slot remains intact on disk. This enables instant rollbacks and ensures no reader encounters a half-written directory state, as the old files remain fully available until the next publishing cycle overwrites that slot.

Why This Approach Guarantees Atomicity

Atomic rename(2). The POSIX rename system call ensures the symlink points to either the old or new slot, never an intermediate or corrupted state. If the system crashes during the rename, the symlink要么 retains its old value, maintaining consistency.

Directory-level isolation. Each slot is a completely separate directory tree. Writers populate one tree while readers consume the other, eliminating race conditions between file creation and HTTP response generation.

Graceful Windows fallback. For non-POSIX platforms, the implementation first removes any stale temporary symlink before executing the rename, preserving the same atomicity guarantees across operating systems.

Implementation Code Examples

Flipping the active slot demonstrates the public API:

import { swapSlot, getActiveSlot, getInactiveSlot } from '@server/publish/staticArtefact';
import { uploadsDir } from '@server/config';

async function publishNewVersion(staticFiles: Buffer[]) {
  // Step 1: Identify the safe slot to write to
  const inactive = await getInactiveSlot(uploadsDir);
  
  // Step 2: Write files to uploads/published/${inactive} ...
  await writeToSlot(uploadsDir, inactive, staticFiles);
  
  // Step 3 & 4: Atomically swap the symlink
  await swapSlot(uploadsDir, inactive);
  
  // Verify the swap succeeded
  const nowLive = await getActiveSlot(uploadsDir);
  console.log(`Now serving from slot: ${nowLive}`); // 'a' or 'b'
}

Reading through the symlink in the request handler:

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

export async function servePage(path: string) {
  // The kernel resolves `current` atomically on each open()
  const fullPath = join(uploadsDir, 'published', 'current', path);
  return await readFile(fullPath, 'utf8');
}

Key Source Files

Understanding the implementation requires examining these specific locations:

Summary

  • Two-slot isolation: Instatic maintains separate a and b directories to isolate read and write operations.
  • Atomic symlink swap: The rename(2) system call provides an instantaneous, non-interruptible switch between versions.
  • Zero-downtime deployments: Readers always see complete file sets, never partial writes or mixed states.
  • Cross-platform safety: Special handling ensures atomicity on both POSIX systems and Windows.

Frequently Asked Questions

On Windows, the implementation removes any existing current.tmp symlink before creating the new one, then performs the rename. This sequence ensures that even without POSIX atomic rename semantics for symlinks, the system never enters an inconsistent state where the current pointer is corrupted.

What happens if a publish crashes while writing to the inactive slot?

If the process fails before the symlink swap occurs, the current symlink remains pointing to the old slot. Readers continue receiving the previous version, and the next publish attempt simply overwrites the incomplete data in the inactive slot before attempting the swap again.

Can readers access the old slot after a atomic swap completes?

Yes. The old slot remains fully populated and accessible through its direct path (uploads/published/a or b), though production traffic routes through current. This enables instant rollbacks by manually restoring the symlink if the new version contains critical errors.

How does the router know which slot to serve without checking filesystem state?

The router does not track slot state directly. It always reads from uploads/published/current, and the kernel resolves this symlink at the moment of each open() call. This design delegates atomicity to the filesystem layer, eliminating the need for the application to manage synchronization locks or state checks.

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 →