---
title: Dynamic Images
url: "https://native-sdk.dev/docs/dynamic-images"
docs_index: /llms.txt
lastUpdated: 2026-10-16
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Load images at **runtime** — from disk or the network — decode them through the platform codec, and show them in views by id. `Cmd.imageLoad` is effects-as-data all the way down: your core names a model-owned `ImageId` and a source, the host does the I/O and the decode, and exactly one result Msg comes back — `loaded` with the decoded dimensions, or an error state. Views bind the id; nothing in `update` ever touches a file, a socket, or a codec.

Declare static images in the [manifest’s `assets.images`](/docs/app-zon) — read once at launch, registered before the first frame. This page is the **dynamic** half: cover art fetched from a CDN, a chart PNG a subprocess rendered, an avatar that arrives after login.

## Load, then show

The id is ordinary model data — pick it like an effect key. Issue the load, and write the id into the model **only when the result says `loaded`** (the store-on-success discipline): the view renders its fallback while the load is in flight and keeps it when the load failed, because an id of `0` — the no-image sentinel — draws nothing.

```ts
import { Cmd, asciiBytes } from "@native-sdk/core";

// The engine's outcome vocabulary — a named alias your core declares
// (the host matches the members by name, so the order is yours).
export type ImageState =
  | "loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed"
  | "tls_failed" | "protocol_failed" | "timed_out" | "http_status"
  | "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full"
  | "alloc_failed";

export interface Model {
  readonly cover: number; // 0 until the load lands
  readonly coverState: ImageState;
}

export type Msg =
  | { readonly kind: "show" }
  | { readonly kind: "cover_done"; readonly id: number; readonly state: ImageState; readonly width: number; readonly height: number; readonly status: number };

export function initialModel(): Model {
  return { cover: 0, coverState: "rejected" };
}

export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
  switch (msg.kind) {
    case "show":
      return [model, Cmd.imageLoad(21, {
        path: asciiBytes("art/cover.png"),
        url: asciiBytes("https://cdn.example.com/art/cover.png"),
      }, { event: "cover_done" })];
    case "cover_done":
      if (msg.state === "loaded") return { ...model, cover: msg.id, coverState: msg.state };
      return { ...model, coverState: msg.state };
  }
}
```

```zig
const Model = struct {
    cover: canvas.ImageId = 0, // 0 until the load lands
};

// in update:
.show => fx.loadImage(.{
    .id = 21,
    .path = "art/cover.png",
    .url = "https://cdn.example.com/art/cover.png",
    .on_result = Effects.imageMsg(.cover_done),
}),
.cover_done => |result| if (result.outcome == .loaded) {
    model.cover = result.id;
},
```

The view binds the same model id. Markup's `image` element is the display-only leaf for exactly this — the binding is required (an unbound `<image>` is dead markup), the id is always `{binding}` model data (never a literal), and avatars carry the same attribute with an initials fallback:

```html
<image image="{cover}" width="120" height="80" label="Cover art" />
<avatar image="{avatar}" label="Octocat">OC</avatar>
```

The moment the result lands and `update` stores the id, every view referencing it repaints with the pixels — GPU caches re-upload off the changed content fingerprint, no invalidation calls. Zig views use `ui.image(.{ .image = model.cover, ... })`; both markup engines refuse a negative id binding at build time with a diagnostic, never a trap.

## The source cascade

The source resolves the way `Cmd.audioPlay` resolves — local first, network last, with a verified cache between:

1. **The local `path` is tried first.** A missing file falls through to `url` (when one is given); every other local failure is terminal — retrying a different source would mask the real problem.
2. **A verified cache entry loads without the network.** A URL source checks its cache path before fetching; `expectedBytes` (when you know the size, e.g. from a manifest) is the integrity gate — an entry whose size disagrees is discarded and re-fetched.
3. **The network fetch installs the cache behind it.** The bytes are fetched whole (no truncated delivery — a cut image can never decode), written beside the cache path, and atomically renamed into place only after the size verifies, so a partial download never occupies the cache name.

Prefer **omitting** `cachePath` for URL sources: when the app wiring configures a caches directory (`TsUiApp`'s `image_cache_dir`; the app runner points it at the platform caches directory automatically), the host derives the conventional content-addressed path — `<caches>/images/<sha256[..16]>.<ext>` — from the URL itself. Your update never builds filesystem paths, replay re-derives the same path by construction, and clearing the cache is deleting one directory the OS already treats as reclaimable.

<span id="one-result-honest-classes" />

## Load outcomes

Exactly one event arm dispatches per load — a five-field record matched by field name: `id`, `state`, `width`, `height`, `status`. `id` echoes the requested ImageId, so two loads in flight at once share one arm and still tell their results apart (that is what the example's `cover: msg.id` reads); `status` is the HTTP status for url loads that performed an exchange, and **0 when none occurred** — local paths and cache hits — so a `loaded` event with status 0 means the load did not perform an HTTP exchange; `loaded` means the pixels are registered and drawable; everything else names what actually happened:

- **Source classes** — `not_found` (missing local file, no url), `io_failed` (a local read failure), `connect_failed` / `tls_failed` / `protocol_failed` / `timed_out` (the fetch taxonomy, on the fetch machinery's own timeout), `http_status` (a non-2xx answer, with the status carried through — an error page is not an image, so the body is discarded).
- **Decode and registry classes** — `decode_failed`, `unsupported` (a host without a codec), `registry_full`, and `alloc_failed` (the host refused the memory the registration needed — resource exhaustion, not corrupt bytes: the same source may load once memory frees, so it is never reported as `decode_failed`). `too_large` normally means the encoded source exceeded 8 MiB; platform codecs fit decoded pixels to the app's declared target.
- **Discipline classes** — `rejected` (an invalid id, no source at all, or a duplicate live id: one load per id at a time, the spawn rule — a load in flight is never replaced implicitly) and `cancelled` (`Cmd.imageCancel(id)` ended the load).

`Cmd.imageCancel(id)` is the load's cancel — image loads are keyed by their numeric id, so the string-keyed `Cmd.cancel` never touches them. Cancel is **loud**, the spawn discipline: the one terminal still arrives, as the load's own event arm with state `cancelled`, and the id is free for a fresh load once it lands (a slow CDN fetch no longer pins its id against a retry). Aimed at an id with no live load it no-ops — whatever it targeted already delivered its terminal.

## Releasing images: the gallery eviction

A loaded image occupies one of the registry's **16 slots** until you release it, and `Cmd.imageUnregister(id)` is the release: the pixels are freed, views still referencing the id draw their fallback (avatar initials, nothing for `<image>`) on the next frame, and the slot is open for another load. For a gallery with more images than registry slots — a screen paging through covers loads the visible ids, and when the 17th image would answer `registry_full`, unregisters an off-screen evictee first (mint fresh ids for new content, effect-key style; never re-key different content onto a live id — the same rule the Zig tier's `fx.unregisterImage` documents).

Unlike a load, unregister is **synchronous registry surgery, not an effect**: no result Msg follows (registration by `imageLoad` has a terminal because I/O and decode can fail; releasing a slot cannot), and aimed at an id with no registration it no-ops — `imageCancel`'s idle rule. It frees only the **current** registration: a load in flight under the id is untouched, and its terminal still registers the pixels, re-occupying the id. To evict an id whose load is still running, `Cmd.imageCancel(id)` first, then unregister.

When many small visuals ship together, one slot can hold a texture atlas instead: `<image>` and `<avatar>` accept `source-x`, `source-y`, `source-width`, and `source-height` together, in decoded-image pixel coordinates. Zig views set the equivalent `ElementOptions.image_src` rectangle. Cropped widgets use nearest sampling so adjacent regions cannot bleed into the tile. Every atlas region references the same registered ImageId, so it consumes one registry slot; use the dimensions reported by the load result because decode-to-fit may change the atlas geometry.

<span id="limits-honestly" />

## Resource limits

The default is **16 slots** with a **1 MiB decoded-pixel target per slot**. That target is not a refusal: platform codecs decode photo-scale sources down, preserving aspect, until `width × height × 4` fits. A wide 1024×256 image already fits and stays that size; a 640×480 image registers at a smaller geometry, and the result's `width`/`height` report exactly what views draw. The encoded source has its own flat **8 MiB** bound; an over-bound source fails whole with `too_large`, never as truncated bytes.

Image-centric apps can raise the target in the app manifest, up to the hard 8 MiB ceiling:

```zig
.images = .{ .max_image_pixel_bytes = 8_388_608 },
```

The value is frozen at startup. Pixel blocks and decode scratch allocate lazily, so declaring a raise costs nothing until an image is used; each used registry slot is a whole-budget block, however, so filling all 16 slots at 8 MiB is a declared **128 MiB** high-water. The source bound stays 8 MiB regardless of this setting.

This gives three clear tiers: decode-to-fit is the default for feeds, galleries, avatars, and covers; the manifest raise serves wallpaper and image-forward apps that want more display-scale detail; pixel-exact editing, source-resolution zoom, and gigapixel tiling belong in an app-owned `gpu_surface` or [media-surface producer](/docs/media-producers), not the registry. The framework still bundles no codecs: bytes decode through CGImageSource on Apple platforms, gdk-pixbuf on GTK, WIC on Windows, BitmapFactory on Android, and the embed image service — a host without one answers `unsupported`.

For textures **produced** by your own renderer at video rates — a decoder, a camera, mpv — this is the wrong tool: that is the [media surface](/docs/media-producers)'s dynamic texture channel. `imageLoad` is for images that exist as encoded bytes somewhere and should become long-lived registered pixels.

## Recorded sessions replay byte-identical, offline

The loaded bytes **are** the effect result, and the session journal treats them that way. When a recorded session performs an image load, the encoded source bytes are written — at effect-result time — into a content-addressed blob store beside the journal (`blobs/<sha256[..16]>` in the session directory), and the journal record carries the hash and length. Loading the same bytes twice stores one blob: content addressing is deduplication — and the deduplicating probe verifies the existing blob's bytes before trusting its name, so a damaged blob is repaired in place on the next same-bytes recording rather than sealing a journal replay would refuse.

Replay reads the blob, re-runs decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. If the current manifest lowers the image budget after recording, the recorded result dimensions still replay verbatim while best-effort presentation re-decodes to the current budget; any resulting screenshot difference is a verification mismatch, not false journal damage. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v7** — older journals are refused at the preamble with a diagnostic requesting a new recording.

```sh
NATIVE_SDK_SESSION_RECORD=session/app.journal native run   # records blobs/ beside the journal
NATIVE_SDK_SESSION_REPLAY=session/app.journal native run   # replays offline, verifying fingerprints
```

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)