# Instatic Automatic Detection and Lazy-Loading of Dynamic Content Holes

> Instatic automatically detects and lazy loads dynamic content holes. Optimize your page performance by analyzing request-dependent data sources and loading content on demand with IntersectionObserver.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-08-02

---

**Instatic automatically detects dynamic content holes by analyzing the page tree for request-dependent data sources, then emits placeholder elements that lazy-load via IntersectionObserver when they enter the viewport.**

The CoreBunch/Instatic repository implements a three-layer publishing architecture (A‑C) that separates static site generation from request-time rendering. At the heart of this system lies the **automatic detection and lazy-loading of dynamic content holes**—subtrees that cannot be fully rendered at publish time because they depend on runtime data such as `route.query` parameters or per-visitor loop sources. This ensures the initial HTML remains cacheable and lightweight while deferring only the necessary fragments to the browser.

## Detecting Dynamic Nodes in the Publishing Pipeline

The single source of truth for dynamic node classification resides in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts). The exported function `findDynamicNodeIds` walks the entire page tree and returns a `Set<string>` of node IDs that must be deferred to request time.

```ts
findDynamicNodeIds(page: Page, site: SiteDocument, registry: IModuleRegistry)

```

This function applies four distinct rules (lines 62‑68) to determine if a node constitutes a dynamic hole.

### The Four Detection Rules

Instatic marks a node as dynamic when any of the following conditions are met:

- **Module-level dynamic flag** — The module definition has `dynamic: true` (lines 88‑92).
- **Request-dependent dynamic bindings** — The node contains a `dynamicBindings` entry whose source relies on `route.query.*` or other request-time data.
- **Inline token interpolation** — A string prop contains `{source.field}` tokens where the source is request-dependent (handled by `checkInlineTokens` at lines 22‑33).
- **Per-request loop sources** — The node has a `base.loop` whose source is marked `requestDependent` or `perVisitor` (checked by `checkLoopSource` at lines 43‑52).
- **Recursive Visual Component inspection** — A `base.visual-component-ref` whose definition tree contains any dynamic node, making the entire VC reference a hole boundary (guarded by a `seenVcs` set to prevent cycles).

The detection logic is consumed by two critical layers: **Layer A** ([`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts)) uses it to emit placeholders during static generation, while **Layer C** ([`server/handlers/cms/hole.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/hole.ts)) uses the same set to render fragments on demand.

## Emitting Lazy-Loading Placeholders

During the static generation phase, `publishPage` invokes `renderNode` from [`src/core/publisher/renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/renderNode.ts). When the walker encounters a node ID present in the `holeNodeIds` set, it emits an `<instatic-hole>` placeholder instead of recursing into the subtree (lines 202‑314):

```html
<instatic-hole id="hole-{safeId}"
               data-instatic-hole="{safeId}"
               data-instatic-version="{version}"
               style="display:contents">
</instatic-hole>

```

If the page contains at least one dynamic hole, the system automatically injects the hole-runtime script into the document `<head>` (see [`render.ts`](https://github.com/CoreBunch/Instatic/blob/main/render.ts) lines 370‑378).

## The Hole Runtime and Server-Side Rendering

The lazy-loading mechanism consists of a lightweight client-side script and a server-side fragment endpoint that cooperate to populate placeholders with actual content.

### Client-Side IntersectionObserver

The runtime script, defined as `HOLE_RUNTIME_JS` in [`server/publish/holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/holeRuntime.ts), is served at the fixed path [`/_instatic/hole-runtime.js`](https://github.com/CoreBunch/Instatic/blob/main//_instatic/hole-runtime.js). The script:

1. Queries all `<instatic-hole>` elements in the DOM.
2. Registers an `IntersectionObserver` on each element.
3. Fetches the fragment from `/_instatic/hole/<nodeId>?v=<publishVersion>&u=<pageUrl>` when the element enters the viewport.

### Server-Side Fragment Rendering

The `handleHoleRequest` function in [`server/handlers/cms/hole.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/hole.ts) processes these requests. It renders the requested subtree fully (without further hole emission) using the same publishing pipeline but omitting the `dynamicNodeIds` check (lines 44‑46). The endpoint also stamps a **form token** (lines 53‑56) to ensure CMS forms inside holes can submit correctly.

Version validation occurs at lines 84‑96: if the requested `v` parameter does not match the current publish version, the endpoint returns a stale sentinel:

```html
<instatic-hole-stale data-instatic-stale="true">

```

This prompts the client to reload the entire page on the next navigation, preventing content drift.

### Caching Strategies

Instatic differentiates between shared and personalized content:

- **Shared holes** — For sources that are not `perVisitor`, the rendered fragment is cached in Layer B keyed by `(nodeId, page-query, version)`. Subsequent visitors receive the cached HTML without invoking the rendering pipeline.
- **Per-visitor holes** — When the loop source is marked `perVisitor`, the handler bypasses Layer B and sets `Cache-Control: no-store` (lines 101‑106), ensuring each request re-renders the fragment for the specific visitor.

## Code Examples

*Detecting dynamic nodes in a custom script:*

```ts
import { findDynamicNodeIds } from '@core/publisher/dynamicDetection';
import { registry } from '@core/module-engine';

async function logDynamicNodes(page, site) {
  const dynIds = findDynamicNodeIds(page, site, registry);
  console.log('Dynamic node IDs:', [...dynIds]);
}

```

*Manually triggering a hole fetch for testing:*

```ts
async function fetchHole(nodeId: string, pageUrl: string, version: number) {
  const resp = await fetch(
    `/_instatic/hole/${encodeURIComponent(nodeId)}?v=${version}&u=${encodeURIComponent(pageUrl)}`
  );
  const html = await resp.text();
  console.log('Hole fragment:', html);
}

```

*Customizing the hole-runtime script with debug logging:*

```ts
// In a plugin bootstrap file
import { HOLE_RUNTIME_JS } from '../../publish/holeRuntime';
const debugRuntime = HOLE_RUNTIME_JS.replace(
  'const observer = new IntersectionObserver(',
  'console.log("hole runtime active"); const observer = new IntersectionObserver('
);
export const HOLE_RUNTIME_JS = debugRuntime;

```

## Summary

- **Dynamic detection** is centralized in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts), where `findDynamicNodeIds` applies four rules to identify request-dependent subtrees.
- **Placeholder emission** occurs in [`src/core/publisher/renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/renderNode.ts), which outputs `<instatic-hole>` elements for each dynamic node ID.
- **Lazy-loading** is handled by the `HOLE_RUNTIME_JS` script, which uses `IntersectionObserver` to fetch fragments only when they become visible.
- **Server-side rendering** of holes happens in [`server/handlers/cms/hole.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/hole.ts), with built-in version validation and form-token stamping.
- **Caching** is split between shared fragments (cached in Layer B) and per-visitor content (Cache-Control: no-store).

## Frequently Asked Questions

### What triggers a node to be marked as dynamic in Instatic?

A node becomes dynamic when it meets any of the four criteria defined in [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/dynamicDetection.ts): the module declares `dynamic: true`, it contains bindings to `route.query` or other request-dependent sources, it uses tokenized strings requiring runtime interpolation, or it references a Visual Component whose definition tree contains dynamic nodes. Loop sources marked `perVisitor` also trigger dynamic classification.

### How does the lazy-loading mechanism work for dynamic content holes?

The system emits an `<instatic-hole>` placeholder during static generation. The browser loads a lightweight runtime script ([`/_instatic/hole-runtime.js`](https://github.com/CoreBunch/Instatic/blob/main//_instatic/hole-runtime.js)) that registers an `IntersectionObserver` on these placeholders. When a placeholder enters the viewport, the script fetches the rendered HTML fragment from the `/_instatic/hole/<nodeId>` endpoint and replaces the placeholder with the actual content.

### What happens if a user visits a page with an outdated publish version?

The hole endpoint validates the `v` (version) query parameter against the current publish version. If they mismatch, the server returns an `<instatic-hole-stale>` element instead of the fragment. The client-side runtime interprets this as a signal to reload the entire page on the next navigation, ensuring the user receives fresh, consistent content.

### How does Instatic handle caching for personalized content?

For holes with `perVisitor` loop sources, the `handleHoleRequest` function in [`server/handlers/cms/hole.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/hole.ts) sets `Cache-Control: no-store` and bypasses Layer B caching entirely. For all other dynamic holes, the system caches the rendered fragment in Layer B using a key composed of the node ID, page query parameters, and publish version, allowing efficient reuse across visitors.