Guides

Instant first-frame rendering

Vue Lynx 0.5 can render your first screen on the main thread before any background JavaScript runs, which removes the blank frame at launch. The flag, the rules your components have to obey, and what it costs.

A Lynx app launches into nothing. The main thread loads the template and waits while the background thread boots its JavaScript engine, evaluates your bundle, renders the Vue tree, and ships an ops batch back across the thread boundary. Vue Lynx 0.5 added Instant First-Frame Rendering to close that gap: with enableIFR: true the main-thread bundle carries the Vue runtime and your app, so the first screen renders synchronously inside loadTemplate while the background thread is still starting.

The background thread then hydrates against a recording of the ops the main thread already applied, and takes ownership of the tree. Vue Lynx's own page documents that machinery. This page is the part that touches your components, where a render running twice changes what you are allowed to write.

Turning it on

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy'
import { pluginVueLynx } from 'vue-lynx/plugin'

export default defineConfig({
  plugins: [
    pluginVueLynx({
      enableIFR: true,
    }),
  ],
})

Element Templates come with it, because enableIFR turns on enableElementTemplates unless you say otherwise. Nothing in a typical component tree has to change.

Keep the first render deterministic

The render now executes once per thread, so it has to be a pure function of its inputs. Math.random(), Date.now(), and a branch that tests which thread it is running on all produce two different trees, and the background pass then tears down the first frame and rebuilds it from scratch.

Rule for first screens: everything the render reads has to resolve the same way on both threads.

Put side effects in Composition API hooks

Vue Lynx re-exports onMounted, onBeforeMount, onUpdated, onUnmounted, onActivated and their siblings wrapped so registration is a no-op during the main-thread pass. Fetching, timers, and subscriptions written in those hooks therefore run once, on the background thread.

Options API mounted() is not suppressed yet, so a component with a side effect there runs it on both threads. Passing optionsApi: false to the plugin, as our example apps do, keeps the question from arising.

Mount a shell rather than skipping the mount

IFR only paints what exists synchronously, so the instinct on a network-driven screen is to skip app.mount() entirely. Skipping it buys the larger main-thread bundle and no first paint at all, which is the worst of both configurations. Mount, render a skeleton, and gate the network work instead.

import { isIfrMainThread } from 'vue-lynx'

const { data } = useQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  enabled: !isIfrMainThread(),
})

Vue Lynx's own Hacker News example moved from skipping the mount to painting a chrome-and-spinner shell, and its measured FCP went from 38% worse than the no-IFR build to 12% better.

Read the hydration warning as a performance bug

Identical ops batches are skipped because their result is already on screen, and text, style, and attribute differences are patched in place. A structural difference removes the first-frame tree and rebuilds it from the background ops, which logs [vue-lynx] IFR hydration mismatch in development.

Correctness never depended on the two renders matching, so the app still renders. What you lost is the speedup, which makes that warning worth chasing rather than silencing.

Leave enableElementTemplates alone

Element Templates change how much work the synchronous render does. The compiler collapses a static subtree into a generated create() function and sends one INSTANTIATE_TEMPLATE op instead of per-node ops, patching only the dynamic holes afterwards. On a roughly 1,000-element scene that takes the render from about 8.4ms to 1.3ms, and a 1,400-element static screen ships 69 bytes of ops instead of 78KB.

A subtree qualifies when every node is a plain Lynx element and only property values or text content vary:

<view class="card">                      <!-- lowered -->
  <image class="icon" src="a.png" />     <!-- baked into the skeleton -->
  <text class="title">{{ title }}</text> <!-- dynamic text hole -->
  <view :class="badgeClass" />           <!-- dynamic class hole -->
</view>

Components, slots, v-if and v-for hosts, refs, keys, runtime directives, <list>, and mixed dynamic text runs stay on the normal vnode path, though the plain-element bodies inside them can still be lowered. Scoped CSS survives, since the compiler bakes the scope ID into each lowered element. Reach for enableElementTemplates: false only to bisect a suspected lowering bug, never as a performance setting.

Dev-only: the HMR WebSocket polyfill breaks the main-thread pass

rspeedy dev injects WebSocket into Lynx builds through a ProvidePlugin pointed at @lynx-js/websocket, and that module calls lynx.getJSModule('GlobalEventEmitter') at module scope. The function does not exist on the main thread, so any graph holding a reference to WebSocket throws during the IFR pass. Every Vy UI app holds one, because useForwardExpose in @vyui/core imports @vueuse/core and the kit plugin imports core.

What you see does not point at any of that. Evaluation dies before app.mount(), no element template ever registers, the screen fills with placeholder boxes, and the console repeats [vue-lynx] Unknown element template "…" on the main thread — rendering a placeholder.

Production builds do not inject the polyfill, so this is a dev-server problem only. Until the polyfill defers that call, give the main thread something to find before the rest of your imports evaluate:

src/index.ts
// Must come first — the polyfill runs at module scope.
const l = lynx as unknown as { getJSModule?: (name: string) => unknown }
if (typeof l.getJSModule !== 'function') {
  l.getJSModule = () => ({ addListener() {}, removeListener() {}, trigger() {}, emit() {} })
}

Pointing dev.client.websocketTransport at a module that requires the real polyfill lazily works too, and costs a build-config entry instead of a global patch.

Expect a short dead zone for events

Between first paint and background hydration the handler registry does not exist, so a tap in that window can be dropped. Main-thread script handlers are unaffected because they already live on the main thread, which covers Vy UI's gesture surfaces.

Watch the bundle, especially on web

The application now lives in both bundles and evaluates on both threads.

costmeasured on Vue Lynx's example suite
main.lynx.bundle gzipabout 2.26× larger at the median
Serial-work TTI proxyabout 35% higher
Large apps under 4× CPU throttleFCP 25% to 44% worse on Elk and AI Chat
Content-first screens, full speedFCP roughly 12% to 26% better

A kit app lands at the heavier end of that range. A 30-card screen built from @vyui/kit went from 140.2KB to 367.8KB gzipped, or 2.62×, because the styled components and their theme tables now ship on both threads.

On the web, bundle parse sits directly on the FCP path, so the flag that wins on a small content-first screen can lose on a large one over a throttled CPU. Vue Lynx publishes both results rather than one headline number, and its benchmark page has the full campaigns.

your first screendo this
Renders from synchronous dataEnable it
Fetch-driven, but has a real shell or skeletonEnable it, and gate fetches on isIfrMainThread()
Fetch-driven with nothing useful to paintBuild the shell first, then enable
Large bundle, slow CPU, web targetMeasure before committing, since throttling can reverse the win

CSS Module class names are a smaller surprise in the same area, since they can appear un-hashed on the first frame and get patched to their final names during hydration.

Prove it on your own app

Published medians will not tell you whether your first screen wins, and IFR is unusually easy to test because the same source builds both ways. Read the flag from an environment variable and add a second script:

lynx.config.ts
const enableIFR = process.env.VYUI_IFR !== '0'

export default defineConfig({
  source: {
    define: { __IFR__: JSON.stringify(enableIFR) },
  },
  plugins: [
    pluginVueLynx({ optionsApi: false, enableIFR }),
  ],
})
package.json
{
  "scripts": {
    "dev": "rspeedy dev",
    "dev:noifr": "VYUI_IFR=0 rspeedy dev"
  }
}

Two more things make the difference legible on a device without a profiler. The first is a deliberate boot cost in the entry, gated so it only runs where a real app's startup would:

src/index.ts
if (!isIfrMainThread()) {
  const until = Date.now() + 1200
  while (Date.now() < until) { /* stand in for module eval, store setup, i18n */ }
}

The second is a badge driven by onMounted, which is suppressed on the main-thread pass and therefore reads as false on the first frame:

const hydrated = ref(false)
onMounted(() => { hydrated.value = true })

Launch both builds and watch. With IFR the content paints immediately and the badge holds its pre-hydration state for the length of the spin, and without it the screen stays blank for that whole window before arriving complete. Piping __IFR__ into a second badge leaves a screen recording that labels itself.

Two details keep the harness honest. The marker has to be a text or class difference rather than a v-if, because a structural difference between the passes triggers the rebuild and you end up timing the fallback instead of IFR. List content should come from the index rather than from anything random, for the same reason.

Vy UI under IFR

Our peer range covers vue-lynx@^0.5.1, so the flag is available to any project on the current release. Every component in @vyui/core and @vyui/kit is written with <script setup>, so their lifecycle hooks are the suppressed kind and no component effect fires twice. The IDs behind the accessibility wiring come from useId, which counts render order rather than generating randomness, so both passes produce the same values.

Main-thread gesture code is the part to watch. IFR keeps the worklet-transformed output on the main thread, and our worklets still depend on the includeWorkletPackages entry that installation covers. That requirement does not change under IFR; it only matters more, because more of your code now evaluates on that thread.

Overlays, sheets, toasts, and anything animated mount after the first paint by definition, so IFR neither helps nor harms them. The screen it speeds up is the static content underneath.

What is Vue Lynx

The dual-thread model IFR is working around.

The rspeedy dev server

Where the config in this guide gets picked up.

Testing on your phone

Measuring a first frame where it actually counts.