Instant first-frame rendering
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
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:
// 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.
| cost | measured on Vue Lynx's example suite |
|---|---|
main.lynx.bundle gzip | about 2.26× larger at the median |
| Serial-work TTI proxy | about 35% higher |
| Large apps under 4× CPU throttle | FCP 25% to 44% worse on Elk and AI Chat |
| Content-first screens, full speed | FCP 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 screen | do this |
|---|---|
| Renders from synchronous data | Enable it |
| Fetch-driven, but has a real shell or skeleton | Enable it, and gate fetches on isIfrMainThread() |
| Fetch-driven with nothing useful to paint | Build the shell first, then enable |
| Large bundle, slow CPU, web target | Measure 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:
const enableIFR = process.env.VYUI_IFR !== '0'
export default defineConfig({
source: {
define: { __IFR__: JSON.stringify(enableIFR) },
},
plugins: [
pluginVueLynx({ optionsApi: false, enableIFR }),
],
})
{
"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:
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.