| name | perf |
| description | Speed and memory performance rules for Rust crates, the Node addon, webui-framework, and webui-router. |
Performance
WebUI's value proposition is speed and low memory usage. Every change to core Rust crates, @microsoft/webui-framework, or @microsoft/webui-router must be evaluated through two lenses: throughput (how fast) and memory (how little).
Server memory is not cheap. Client memory is not unlimited. Every allocation that can be avoided is a win on both sides.
Use this skill when modifying any performance-sensitive code across the stack.
Rust - speed rules
These apply to webui-handler, webui-state, webui-expressions, webui-parser, webui-protocol, webui-ffi, and webui-node.
- No
format!() in writer output. Use sequential writer.write() calls. format! allocates a temporary String every invocation.
- No
.to_string() on Cow. Write Cow<str> directly to avoid defeating zero-copy.
- No
collect::<Vec<_>>() on splits. Iterate path.split('.') directly. Collecting allocates a Vec for sequential access.
- No redundant scans. Use
first_part.len() == path.len() instead of path.contains('.').
- No
String::from(ch) in escape loops. Use ch.encode_utf8(&mut buf) with a [u8; 4] stack buffer, or batch contiguous safe chars into a single write.
- No
format! in hex parsing. Use direct arithmetic ((hi_nibble << 4) | lo_nibble) instead of u8::from_str_radix(&format!(...)).
- No per-request template re-parsing. Pre-parse at protocol load time and reuse.
- No silent
unwrap_or defaults. If binding_stack.pop() returns None, that's a protocol error - propagate it, don't mask it.
- Keep cold error/diagnostic construction off the hot path. Mark error builders
#[cold]/#[inline(never)] so they don't inline into hot functions and perturb their code layout. Adding cold error code with no hot-path work has regressed parse benches ~4-5% purely via layout - keep per-element fast-paths inlinable and push the cold fallback out-of-line. See skills/diagnostics/SKILL.md §7.
Rust - memory rules
- No cloning large state trees. Use
evaluate_with_resolver with a closure. Cloning duplicates the entire JSON tree in memory.
- No cloning HashMaps for scope. Save/restore only the overwritten key on loop iteration. A HashMap clone copies every entry.
- No cloning
String values for read-only access. Use s.as_str() for Value::String branches; only create owned strings for Number/Bool via a scratch buffer or Cow.
- Pre-allocate buffers. Use
Vec::with_capacity / String::with_capacity when size is known or estimable. For HTML output, 4096 bytes is a reasonable starting point.
- Prefer
&str and slices over owned types. Pass by reference when the callee only reads. Move clone decisions to the caller.
- Use
Cow<'_, str> when a value is sometimes borrowed, sometimes owned. Avoids unconditional allocation.
- No deep-cloning protocol or state per request. Use
Arc<T> with clone-on-write or snapshot swapping.
- Cap memory for untrusted inputs. File reads during discovery must have size limits. A 100MB HTML file should not cause OOM.
TypeScript - @microsoft/webui-framework rules
These apply to packages/webui-framework (the client-side Web Component runtime).
Speed
- Single-pass hydration. The framework walks the DOM once to connect all bindings. No multi-pass scanning.
- Path-indexed targeted updates. When an
@observable changes, only bindings referencing that property are visited - not the entire template.
- DOM cloning over innerHTML. Use
cloneNode(true) from cached template fragments. Never use innerHTML for component creation.
- Direct event listeners. Attach
@event bindings to the bound element, never a shared render root. $wireEvents runs once per block instance, so delegating stacks one listener per block on the same node and fires all of them per dispatch — O(N) for no reduction in listener count.
- Microtask coalescing. Multiple property changes within the same synchronous block batch into a single DOM update via
queueMicrotask.
- Cursor-based repeat reconciliation.
<for> block updates use a diff algorithm that only calls insertBefore on nodes that actually moved. Append/prepend/remove are O(1).
- No
for..in on objects. Use Object.keys() with an indexed for loop — faster and prototype-safe without needing Object.hasOwn. Applies to setState, setInitialState, and any code iterating user-provided objects.
Memory
- No framework in the GC. Minimize object allocations during reactive updates. Reuse binding objects, don't recreate them.
- Template cache is
WeakMap-keyed. Parsed template DOMs are cached per metadata object. When metadata is released (e.g., via Router.gc()), the cache entry becomes GC-eligible.
- No per-update array allocations. Avoid
.filter(), .map(), .slice() in the update hot path. Use index-based iteration.
- Strip SSR markers after hydration. Comment nodes used as markers are removed from the DOM once wiring is complete - they don't persist as memory overhead.
- Scope frames are stack-allocated.
<for> loop item variables use a linked-list scope chain, not cloned Maps or Objects.
TypeScript - @microsoft/webui-router rules
These apply to packages/webui-router (the client-side SPA router).
Speed
- Server does route matching. The client does not re-match routes. The server returns the matched
chain array; the client diffs old vs new and mounts only changed components.
- Lazy loading via dynamic import. Route component JS is fetched only on first navigation to that route.
- Chain diffing, not full remount. Navigating between sibling routes preserves parent components. Only the changed level is remounted.
- No
for..in on objects. Use Object.keys() with an indexed for loop. for..in walks the prototype chain (slow) and requires an Object.hasOwn guard to be safe — Object.keys is both faster and prototype-safe in one call:
for (const key in obj) { ... }
for (const key in obj) {
if (Object.hasOwn(obj, key)) { ... }
}
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) { ... }
Memory
- Release unused templates.
Router.gc() clears cached component templates for routes the user hasn't visited recently. Active route components are never released.
- Inventory bitmask prevents duplicate downloads. The server tracks which component templates the client already has via a bitmask. Re-navigation never re-sends templates.
- Minimal state per navigation. Route-scoped state means the JSON partial contains only what the active route needs, not the full app state.
Measuring
Server benchmarks
cargo bench -p microsoft-webui --bench contact_book_bench
cargo bench -p microsoft-webui --bench contact_book_bench -- --test
cargo bench -p microsoft-webui --bench server_request_bench
cargo xtask bench all
cargo xtask bench node-addon
Compare Render/1000 P50 before and after. For Node changes, save with
--save-baseline before and compare with --baseline before. Verify output
Bytes is unchanged (same HTML = correct behavior).
Client performance
window.addEventListener('webui:hydration-complete', () => {
for (const entry of performance.getEntriesByType('measure')) {
if (entry.name.startsWith('webui:hydrate:')) {
console.log(`${entry.name}: ${entry.duration.toFixed(1)}ms`);
}
}
});
What to report
When making a performance-related change, report:
- Before/after benchmark numbers (P50 latency, throughput)
- Allocation count delta if measurable
- Output size unchanged (proves correctness)
- Memory profile for memory-related changes (heap snapshots, RSS delta)