How Pax Runs
A Pax application has a reactive scene behind the interface you see. Its properties carry values, its component instances organize the scene, and its platform integration brings together drawn content and native controls. Understanding those pieces helps you explain an update, investigate a slow interaction, and choose what to measure before shipping.
The same core model runs on web, macOS, iOS, and iPadOS. This chapter follows it from source to the screen. It builds on Properties, Events, and Layout; What is Pax? introduces the authoring model.
Compiler, runtime, and chassis
Three parts work together to run your application:
| Part | Responsibility |
|---|---|
| Compiler | Analyzes the application and its templates, prepares the declarative program, and generates the connections to Rust component types and handlers |
| Runtime | Instantiates the scene, maintains reactive relationships, handles events, computes layout, and coordinates rendering and native-element updates |
| Chassis | Connects the runtime to a target's window or browser, graphics surfaces, input, native elements, and platform services |
Cargo compiles Rust application logic and expands Pax's macros for the selected
target: WebAssembly for the browser, or native code for Apple targets. The macros
analyze the Rust source and templates selected by that target's configuration,
then generate program data describing templates, bindings, events, and timelines.
pax-cli handles asset packaging, web serving, hot reload, and Apple deployment.
On macOS, ordinary cargo run runs the native application directly, without a
preceding CLI build or a development server. init! initializes within that
process; it never selects or launches another development target.
Reusable component libraries supply the source configuration selected by their
own compilation, including dependency features and custom cfg values. A library
can live in a different Cargo workspace: macro expansion does not resolve or
fetch that workspace's unrelated dependencies. This works with ordinary Cargo
path dependencies and does not require an application build.rs or CLI setup.
You may encounter the word cartridge in build output or engine code. It
names the application-specific part that connects your program to the shared
runtime. runnable! generates this connection during compilation; you do not
maintain a cartridge file by hand. On Apple platforms it remains a separately
linked library, which a Rust launcher or an existing host can mount. See
entry and mounting.
Build for each destination target—there is no single executable
cartridge file that you can interchange between a browser and an iOS app.
At a high level:
.pax templates + Rust application + asset references
│
compiler / build
│
target application + packaged assets
│
runtime mounts the main component
│
reactive scene + platform chassis
│
drawn surfaces + native elements
Local assets are included in the target's output as appropriate; external URLs may still be loaded at runtime. An image or font referenced by the program is not necessarily embedded inside its Wasm or native executable.
Architecture schematic
Explore how the compiler, scene graph, reactive runtime, rendering engine and platform chassis connect. Select a part for its role, connections and related docs; select a connection in the inspector to follow it to another part. Use + / − to zoom and Fit to restore the overview. Open standalone gives the drawing more room, and Exit sheet view restores its controls.
For an offline overview, download the full-sheet PNG.
From templates to running instances
A template is a definition. The runtime creates instances from it: component state, element properties, and nodes with resolved layout and parent-child relationships. This running structure is called the expanded tree in inspection and runtime code.
One repeated element in a template can produce many nodes in that tree. A conditional can mount or remove a branch as its condition changes. Slots connect supplied content to the place where a component presents it. These are useful distinctions when the visible scene contains more—or fewer—nodes than you expect from counting tags in a file.
The tree and property graph answer different questions. The tree says where an instance belongs; the graph records which values depend on which inputs. A child's width can depend on its parent's bounds, while its text depends on application state shared from elsewhere. Those dependencies do not have to follow the same shape as the tree.
State also has a lifetime. Removing and recreating a component can recreate
its local state. Keep state that must outlive a particular view in an
appropriate owner, and persist it explicitly when it must survive an
application restart. A Property is a reactive value, not persistent storage.
See Components and Composition for ownership and
shared state.
From a property change to the screen
Consider a handler that increments a counter used in a Text element. The handler sets the source property. Pax marks dependent values dirty: their cached values may no longer be current. When a dependent computed value is needed, the graph evaluates it using the current inputs.
The runtime also drains registered reactive effects that need to push an update into the scene. Multiple writes can settle into one downstream evaluation when that value has not been read or its effect drained between the writes. This does not make a sequence of arbitrary property writes an atomic transaction.
The consequences depend on what changed. New text can require a native text
update and measurement. A different width can change descendant layout. A
new fill can mark drawn content for rendering. Changing an if condition
can alter the mounted tree and its event registrations.
Pax tracks dirty nodes and layers so unchanged drawing can be reused or skipped. The GPU path retains rendering data, and the runtime can avoid a canvas rendering pass when there is no canvas work pending. Native elements receive their own updates through the chassis.
The GPU renderer retains independent transforms and opacity values for scenes with more than 480 transformed drawing operations. It stores transforms and geometric clips in small uniform-buffer pages that grow with scene demand, including on the browser's conservative GPU limits. The page size is an internal batching detail, not a limit on the number of elements you can author. Moving or fading retained vectors can reuse their geometry; a draw spanning multiple transform pages is split while preserving paint order. Memory and rendering cost still grow with the scene, and ordinary visibility culling does not unmount nodes or release their retained identities. Removing nodes makes their slots available for reuse. This behavior is shared by debug and release GPU builds; it does not change the Piet renderer.
The amount of work is therefore related to the dependencies and surfaces affected by a change. A single property write can still have a broad effect: resizing a container, removing an overlapping element, or changing a clip can require work beyond the element you edited. Dirty tracking does not guarantee that every update redraws only the smallest possible pixel region.
Properties covers computed values, subscriptions, and avoiding unnecessary writes. Layout explains the bounds and transforms that these updates can affect.
Work that continues between interactions
The runtime advances clocks and invokes registered lifecycle handlers such
as @tick and @pre_render. A running transition or timeline produces new
values as time advances. These are legitimate sources of work even when
the person using the application is not touching anything.
The current web chassis continues to schedule animation-frame callbacks while running. Dirty tracking reduces work inside those callbacks; it does not establish zero CPU usage at rest or suspend the frame loop altogether. An otherwise static page can still have clock, lifecycle, or platform work.
Keep expensive data processing and repeated no-op state writes out of per-frame handlers when they are not needed. Use a reactive expression for a value derived from other properties, and use the motion APIs for the transitions they describe. Neither choice makes computation free, but it gives the runtime explicit dependencies to work with. See Animation and Motion.
One scene, multiple surfaces
Drawn geometry and native elements share Pax layout, transforms, and ordering. Their presentation is divided across graphics surfaces and platform-owned elements. The compositor coordinates those pieces, including the places where clipping, scrolling, and overlap cross surface boundaries.
For example, a Rectangle can be drawn into a graphics layer while a Textbox is a browser control or Apple-native view. The runtime sends the native element its required updates; user input returns through the chassis to the runtime and application handlers. The platform still handles native concerns such as editing interactions and input-method behavior.
On macOS, native scene updates are applied during the chassis frame cycle, before that frame's GPU submission. Animated native controls therefore receive the current geometry without waiting for a later SwiftUI view update.
This division preserves useful native behavior, while making backend and element type important when choosing an effect. An effect supported on drawn geometry does not automatically support a native control or every form of text and image rendering. The detailed boundaries belong in Compositing and Effects and Accessibility and Native Controls.
Rendering backends
On the web, Pax currently selects its WebGPU renderer where browser support and policy allow. It uses a Piet/CPU browser renderer when WebGPU is unavailable, when the current iOS Safari detection policy selects that path, or when built explicitly with the Piet backend. Piet draws through browser canvases; native browser elements remain native on either path.
Backend selection is not a guarantee of recovery from every graphics-device initialization failure. When diagnosing a browser-specific problem, check the selected renderer and startup errors rather than assuming that another path was selected successfully.
The Apple-native chassis use Pax's GPU rendering integration alongside native views. Running a web build in Safari on an iPhone is a different target path from running a native iOS build on the same phone. Test the destination your users will actually use.
Graphics effects, surface limits, and rendering costs can differ between backends. There is no universal frame-rate guarantee for a Pax application. Scene complexity, assets, device capabilities, and the chosen renderer all contribute to the result.
Viewports and large collections
Clipping and culling can reduce the drawing submitted for content outside a viewport. Scroller surfaces can also be tiled to limit the backing surfaces needed for a large scrollable area. These are rendering optimizations.
The repeated component instances, properties, and application data still
have their own cost. Putting a large for loop inside a Scroller does not
automatically turn it into a virtualized list. Check node count and state
work as well as what is visible. Scrolling and Viewports
covers content measurement and practical collection limits.
Debug and release
Debug builds support the development loop, including richer program metadata and the configured hot-reload lanes. Release builds optimize the compiled application and bake its declarative program into the artifact. The build paths differ internally, so a successful debug run is only one part of checking an application intended for release.
Release applications disable the live .pax and application-logic reload
lanes. Hot reload is a development capability; keeping it available does not
require shipping it in the release application. See
Developer Workflow for the current reload choices.
From your project directory, build the web release with:
pax-cli build --target web --release
Test the resulting application in addition to the debug session. Use the release output when measuring shipping performance or download size. The CLI reports bundle statistics, but those figures do not describe every asset or network request your application may load. Include images, fonts, scripts, and other resources when assessing the complete experience.
For investigating Wasm size, the CLI also offers build --profiling on web,
which produces an optimized build with Wasm names retained for size analysis.
This is a size-investigation mode, not a runtime frame profiler. Measure the
ordinary release build for the shipping comparison.
Inspect an update in a working example
The repository's examples/increment app has a useful pair of behaviors:
clicking its rounded rectangle changes the count and starts a rotation, while
a @pre_render handler continuously changes the rectangle's color.
From a Pax repository checkout, start it with:
pax-cli run --path examples/increment --target web
With that session running, use another terminal in the same checkout:
pax-cli dev inspect tree --path examples/increment --max-depth 3
pax-cli dev logs --path examples/increment --limit 50
The tree shows the running instances behind the template. The log command
reads messages captured by the development session; a quiet session can
return no entries. To include renderer selection, add ?pax_log=info to the
local application's URL and reload it, then run the log command again.
The default debug logging level includes warnings and errors. You can also
read these diagnostics in the browser console. These commands help establish
what is running; they do not measure the time spent inside each part of a frame.
Open src/lib.pax and src/lib.rs inside the example and follow a click:
- The Group binds
@clicktoincrement. - The handler updates
num_clicks, which feeds the Text's expression. - It starts an eased transition on
current_rotation, which feeds the Group'srotateproperty. - The rotation settles, but
handle_pre_renderkeeps updatingticks. The Rectangle's fill depends on that value, so its color keeps changing.
This app intentionally remains animated. To understand activity after an interaction in your own application, follow the same trail: event handler, property dependencies, active motion, and per-frame handlers.
Measure the question you have
Separate startup, interaction, continuous motion, and resting-state work. They can have different causes and require different checks:
- For a slow initial load, inspect resource requests and decoding, executable size, and scene initialization. Compare a cold load with a warm one.
- For an expensive interaction, reproduce one action and inspect the state, layout, and mounted-node changes it causes. Use the browser's performance tools or the target's native profiler to measure where time is spent.
- For motion or scrolling, test realistic content, nested surfaces, and representative devices. Note the target, renderer, viewport, and build mode alongside the result.
- For unexpected activity after the interface settles, look for active transitions, clock-dependent expressions, and per-frame handlers. Distinguish ordinary frame scheduling from repeated application or rendering work.
Screenshots and event driving are useful for repeatable behavior checks. They do not establish frame rate, memory use, or accessibility. Keep those measurements separate, and compare the same workload before and after a change.
Native render phase timings
For native debug or release builds, launch the app process with
PAX_RENDER_TIMINGS=1 to emit aggregated [PaxRender] diagnostics approximately
once per second. This flag is off by default and has no effect in WebAssembly.
For an installed simulator app, for example:
SIMCTL_CHILD_PAX_RENDER_TIMINGS=1 xcrun simctl launch --console-pty \
<simulator-udid> <bundle-id>
Each phase reports count/total_ms/avg_ms/max_ms. render measures the runtime
render call, lighting collection, lighting_upload delivery to the backend,
and prepare retained-node preparation. On the GPU backend, flush, encode,
submit, cleanup, present, and layer_init distinguish drawing and renderer
initialization costs. A render call can collect several layers and flush several
surfaces, so those phase counts and averages are not per-frame counts. Nested
timings overlap: layer initialization can occur inside render or flush, and
encode/submit/present occur inside flush. Do not sum all reported phases.
[PaxRenderTiles] reports counters for the same window: renderer creations,
changes to the keyed tile set, matching key/host tiles discarded during those
changes, origin retargets, resizes, retained-scene resets, and reused tiles. A matching discarded
tile identifies potential reuse; it does not prove that its contents or geometry
were unchanged. Incremental additions should not discard matching survivors;
layer_init can now represent just a batch of missing tiles. Reuse counts include
survivors moved during set reconciliation, not every unchanged tile on every frame.
image_texture_uploaded and image_texture_upload_bytes count new image pixel
allocations/uploads observed at tile flush, while image_texture_reused counts
new tile bindings that reuse a sibling's resident texture. Repainting a tile with
its existing binding is not a shared-cache hit. These are work/transfer counters,
not current GPU memory usage; image bindings are tile-local even when pixels are
shared. Counts include all logical layers, and startup should be excluded
when investigating scroll-induced churn. These counters are also disabled unless
PAX_RENDER_TIMINGS is enabled.
These are CPU wall-clock spans, including any waits inside the measured calls, not GPU execution timestamps or displayed-frame counts. They exclude the chassis's tick, native-view update, and surface synchronization costs. Simulator GPU and presentation timings do not predict performance on physical hardware. Keep startup separate from interaction captures, and relaunch without the flag after measuring to disable the instrumentation.
Read more
- State and Properties — reactive values and dependency management.
- Compositing and Effects — graphics layers, native elements, and effect boundaries.
- Scrolling and Viewports — content extents, scrolling, and collection costs.
- Getting Started — workstation setup, commands, and build output.
- Developer Workflow and Tools — hot reload, inspection, screenshots, and local reference.
- Public API reference — Rust types and methods.
- Runtime and cartridge design notes — historical maintainer context, including architectural directions that are not current builder-facing capabilities.
Shared publication and application lifetime
Application Property<T> storage is shared; graph computations, LocalProperty,
ExpandedNode, layout, and rendering stay on the owning UI thread. A worker
publication coalesces by shared allocation and requests a host wake. The host
validates the current application before entering the engine. The UI imports a
bounded batch of published snapshots before settling local effects; evaluators
never execute on a publishing worker. A raw shared read returns the latest
published snapshot even before a local view has imported it.
Mounted nodes own their local attachment lifetimes. Unmount detaches those
connections; retained raw shared values remain usable as data. The generated cartridge invokes one typed root initializer inside its property
graph. The host adopts that exact root allocation, attaches its wake route and
viewport, then activates staged work. Apple launchers use a real Rust main
with a SwiftUI library bridge; web calls init_web explicitly.
See State and Properties and
Application setup for the implemented
APIs and the current limits on scoped async work. See Entry and mounting for standalone and embedded hosts.