Instant First-Frame Rendering (IFR) v0.5
Instant First-Frame Rendering (IFR) displays real content during
loadTemplate, before the background thread starts. Vue Lynx pairs IFR with
Element Templates, which reduce the work required to create static-structure
subtrees. Together they remove the blank-frame wait and make the synchronous
main-thread render substantially cheaper.
Configuration
Enable IFR in lynx.config.ts:
Element Templates are enabled automatically when IFR is enabled. No app-code changes are required for a typical component tree.
To isolate an Element Templates issue while keeping IFR active, opt out explicitly:
Element Templates can also be enabled independently for ordinary background
rendering with enableElementTemplates: true. This is an advanced composition,
not the recommended IFR setup.
Why IFR removes the blank frame
Without IFR, visible content waits for the background thread to boot, evaluate the application, render Vue's tree, and send an ops batch back to the main thread:
With IFR, the main-thread bundle carries the Vue runtime and application code.
Vue renders synchronously inside loadTemplate, while the background thread
starts in parallel:
The first screen therefore appears before any background JavaScript has run. After hydration, the background thread owns the tree and all later updates use the normal ops pipeline.
Hydration
Both threads execute the same application with the same initial data. The main-thread render records every ops batch it applies. The background thread's initial batches are reconciled against that recording:
- Identical batches are skipped because their result is already on screen.
- Text, style, and attribute differences are patched in place.
- Structural differences remove the first-screen tree and rebuild it from the background ops.
Correctness does not depend on both renders matching. A structural mismatch
only loses the IFR performance benefit and produces an
[vue-lynx] IFR hydration mismatch warning in development.
Event signs and element IDs are deterministic across both renders. Once the background handler registry is ready, events from first-frame elements route to the normal Vue handlers without rebinding.
Element Templates
IFR changes when the first frame is rendered. Element Templates reduce how much work that render performs.
In the ordinary path, each static element still creates a vnode and shadow node, emits several ops frames, crosses the thread boundary, and reaches the main-thread ops interpreter. The compiler can collapse an eligible subtree:
The static skeleton becomes a compiler-generated JavaScript create()
function. Vue sends one INSTANTIATE_TEMPLATE op for the subtree; dynamic text,
classes, styles, and attributes are holes updated through the ordinary
SET_* ops.
What gets lowered
A subtree is eligible when its structure is known at compile time: every node is a plain Lynx element and only property values or text content are dynamic.
Structural features stay on the normal vnode path:
- components, slots, and
v-if/v-forhosts; their plain-element bodies can still be lowered - runtime directives, refs, keys, IDs, and vnode hooks on interior nodes
<list>and<list-item>, which have dedicated main-thread behavior- comments and mixed dynamic text runs
The template root retains its normal vnode props and directives. Scoped CSS is supported: the compiler bakes the component CSS scope ID into each lowered element. Static inline styles are baked only when their semantics are known to match the ordinary style path.
Framework templates, not binary engine templates
Vue Lynx does not currently use Lynx's binary elementTemplates bundle section,
__ElementFromBinary, or __GetTemplateParts. Its Element Templates are a
framework-level optimization built from ordinary typed Element PAPI calls.
This removes vnode, per-node ops, serialization, and interpreter dispatch while preserving the existing renderer and hydration protocol. A binary-template backend could consume the same static-skeleton and hole metadata in the future, but it is not part of this implementation.
Semantics guarantee
Lowering is an optimization, not a rendering mode. Ineligible structures fall
back automatically, and lowered and unlowered trees must produce the same
rendered document and updates. Interior nodes do not carry Vue's selector
bookkeeping attributes, but any element with a ref, ID, or other identity
requirement is kept out of the anonymous interior path.
Writing IFR-friendly first screens
The initial render executes once per thread, so it should behave like a pure function of its inputs:
- Keep first-screen output deterministic. Avoid
Math.random(),Date.now(), or thread-dependent branching in render paths. - Put data fetching, timers, subscriptions, and other side effects in Composition API lifecycle hooks. These hooks are suppressed during the main-thread render and run on the background thread.
- Options API
mounted()is not yet suppressed on the main thread. Prefer Composition API hooks for first-screen components that use IFR. - IFR paints only synchronously available data. A fetch-driven screen should
still mount and render a useful shell or skeleton on the main thread —
gate the network work (for example
useQuery({ enabled: !isIfrMainThread() })), do not skipapp.mount()entirely. Skipping the mount keeps the larger IFR bundle with none of the first-paint benefit. - Regular events can be dropped in the short interval before the background registry is ready. Main Thread Script handlers remain interactive because they already live on the main thread.
Trade-offs
- IFR places the Vue runtime and application in both thread bundles. Across the
example suite,
main.lynx.bundlegzip size grows by about 2.26x at the median. - The application evaluates on both threads. The all-examples serial-work TTI proxy grows by about 35–36%; on a device, main-thread rendering overlaps with background startup.
- IFR cannot accelerate content that does not exist until an asynchronous request completes.
- CSS Modules class names can be un-hashed on the first frame and patched to their final names during hydration.
Measure bundle-sensitive and fetch-driven screens before enabling IFR. For content-first screens with synchronous initial data, IFR + ET is the recommended configuration.
Benchmarks
The table keeps the three product configurations separate. FCP is hello-world
on Lynx for Web with a real Web Worker (no CPU throttle). Render cost is the
warm median for a roughly 1,000-element dynamic-content scene under V8
--jitless. TTI and gzip are medians from the all-examples sweep, normalized
to No IFR. These are separate campaigns; the columns should not be read as one
end-to-end trace.
Across campaigns, the content-first Lynx-for-Web FCP win for default IFR + ET sits roughly in −12% to −26% (medians −19% on a ten-example demo suite; −12% on a later seven-app set that adds TodoMVC / gallery and larger apps). Absolute milliseconds drift by host; ratios are the stable currency. See IFR Benchmarks for both runs.
What the data says
- IFR's win is real and structural on content-first screens. With a
genuine thread boundary (Lynx for Web), IFR removes background-thread boot
- IPC from the critical path — which is also why a single-process benchmark cannot see it. One campaign (ten mostly small examples) saw −8% to −23%, median −19%; a later reevaluation including TodoMVC, gallery, Hacker News (shell IFR), AI Chat, and Elk saw content-first medians around −12% at full speed (hello-world alone about −22% to −26% depending on host). ReactLynx's control on the first harness was −23%, the same class of win.
- Element Templates attack the one cost IFR adds. The synchronous main-thread render gets about 6–15× cheaper across strategy-ladder reruns, and the ops payload shrinks 3–1,000× (a 1,400-element static screen ships 69 bytes instead of 78 KB). On web FCP for small screens this often does not show — the two IFR configurations are usually within a few percent — but the render-cost term grows with screen size and shrinks with CPU speed, which is why ET defaults on with IFR.
- The price is bundle size, and large / fetch-heavy apps can invert.
main.lynx.bundlegzip grows about ×2.2–2.5 (suite median ~×2.26). On the web, bundle parse sits on the FCP path: under 4× CPU throttle, big apps (Elk, AI Chat) have measured +25% to +44% FCP with IFR, while small content-first screens still keep a thinner win. Paint a real shell under IFR; do not skipapp.mount()just because data is async (Hacker News recovered from about +38% to −12% at full speed after switching to shell IFR).
Recommendations
And regardless of profile: keep the first screen deterministic and put side
effects in Composition API lifecycle hooks (see
Writing IFR-friendly first screens);
reach for enableElementTemplates: false only to bisect a suspected
ET issue — not as a performance setting.
The full measurement campaigns — the seven-variant strategy ladder, per-example FCP/TTI/size across the whole example suite, the real-browser dual-thread comparison against ReactLynx, a later large-app reevaluation, and the single-threaded plain-web decomposition — are on the dedicated IFR Benchmarks page.