This is the second article in the Understanding Lynx JS series. In Part 1, I walked through the Quick Start and CLI workflows. This time, I wanted to clarify one thing: how do you reason about Lynx threading in practice?
Lynx is built around a dual-thread runtime. The idea is easy to repeat, harder to internalize, especially when your first demos run on a simulator, and everything looks “fine”. What follows is the path I took to make that model visible.
Quick recap: what Lynx is aiming for
Lynx is a cross-platform UI runtime with a React-style experience. It borrows a lot from the web (CSS styling, keyframe animations, flex layouts), but it’s not “web in a wrapper”, and it’s not React Native under the hood either.
For this article, the important part is the runtime:
- JavaScript runs on two threads: Main Thread and Background Thread.
- The Main Thread is responsible for work that affects the pixel pipeline: layout, rendering, and handling main-thread scripts. The main thread uses PrimJS as its runtime.
- The Background Thread is where your “regular app JavaScript” runs by default: component logic, effects, state updates, and most event handlers.
Lynx gives you a couple of explicit tools to move interaction-sensitive work closer to the UI thread:
- the
"main thread"directive inside a function main-thread:*props likemain-thread:bindscroll
The docs frame this mainly as a way to avoid latency when events are produced on one thread and handled on another (their “event response delay” explanation).
That’s the theory. I needed a way to see it.
My struggle: how do you “see” two threads?
If you come from the web, you usually build intuition around the fact that JavaScript is single-threaded from your code’s point of view. There’s the event loop (one call stack, run-to-completion, tasks/microtasks). When you put too much work on the JS side, the app starts to feel heavy: delayed taps, stuttering animations, unresponsive interactions.
If you come from React Native, you’ve probably felt the same thing: push too much onto JS, and the app starts dropping frames. Not because “everything is one thread”, but because your day-to-day experience of responsiveness often maps back to “is the JS side keeping up?”.
So, I started in the most obvious way: I opened the Lynx docs and followed the tutorials, trying to build intuition through normal usage. We even livestreamed the exploration of the official Gallery tutorial.
The goal was simply to understand the model, not to break anything yet. That approach didn’t fail, but it didn’t teach me what I wanted either.
The Gallery tutorial gives you working code… and hides the feeling
The Gallery tutorial is the first place where Lynx exposes its threading model in a way you can actually observe.
The example renders a masonry-style list of images that you can heart/add to favorites that auto-scrolls and displays two custom scroll indicators. Both scroll indicators reflect the same scroll position, but they are driven by different execution paths. One scrollbar is updated entirely on the background thread, while the other uses main-thread bindings. Visually, they look identical. Under the hood, they are not.
The difference becomes clear once you look at how scroll events are wired. In the Gallery component below, the same list drives two scroll indicators. One reacts to scroll events on the background thread. The other reacts to the same events on the main thread. The UI is shared, but the execution paths are explicit.
Below, you can see a Simplified Gallery.tsx implementation. Take a note at the imports and main thread bindings! This is the Lynx magic at play.
import { MainThread, type ScrollEvent } from "@lynx-js/types";
import { useEffect, useMainThreadRef, useRef } from "@lynx-js/react";
import type { NodesRef } from "@lynx-js/types";
export const Gallery = () => {
const galleryRef = useRef<NodesRef>(null);
const scrollbarRef = useRef<NiceScrollbarRef>(null);
const scrollbarRefMTS = useMainThreadRef<MainThread.Element>(null);
const onScrollMTS = (event: ScrollEvent) => {
"main thread";
adjustScrollbarMTS(
event.detail.scrollTop,
event.detail.scrollHeight,
scrollbarRefMTS
);
};
const onScroll = (event: ScrollEvent) => {
scrollbarRef.current?.adjustScrollbar(
event.detail.scrollTop,
event.detail.scrollHeight
);
};
[...]
return (
<view className="gallery-wrapper">
<NiceScrollbar ref={scrollbarRef} />
<NiceScrollbarMTS main-thread:ref={scrollbarRefMTS} />
<list
ref={galleryRef}
bindscroll={onScroll}
main-thread:bindscroll={onScrollMTS}
...
>
{pictureData.map((picture: Picture, index: number) => (
<list-item>
...
</list-item>
))}
</list>
</view>
);
};You don’t need to install anything to see this in action. The tutorial is available directly on the Lynx website, where you can interact with the demo in the browser. If you want to run it locally, the same example is also available through Lynx Explorer by scanning the QR code from the page. Or you can find my version of this baseline implementation in the example repository here. This is the setup I’ll be using to extend the example and experiment with my own variations.
The promise here is straightforward: work that runs on the main thread stays responsive under load, while work that runs on the background thread becomes more sensitive to JavaScript pressure. This example is meant to show what you actually gain by separating the two.
On an iPhone simulator, however, the default experience is misleading. Even when both paths are wired correctly, both scrollbars look smooth all the time. You can read the code, agree with the documentation, and still walk away without a clear intuition for what splitting work across threads is buying you.
Everything is technically correct. The implementation matches the docs, the example behaves exactly as described, and both execution paths are clearly visible in the source.
What’s missing is friction. Nothing in the running app pushes the model hard enough to make the distinction matter. As long as the background thread stays healthy, there is no obvious reason to care which thread a given piece of logic lives on.
To move forward, the system needs to be put under pressure.
The Jammer trick
Before we explore this in Lynx, let’s create a baseline you can relate to from React Native.
During one of our earlier livestreams, while playing with Reanimated and worklets, we used a small trick to make that behavior obvious. Kevin came up with a tiny helper that does one thing well: it blocks the JavaScript thread on purpose. We started calling it the Jammer.
The idea is deliberately simple. Trigger a state change so the UI updates, then immediately run a tight loop that keeps JS busy for a few seconds. Whatever depends on JavaScript pauses, and whatever doesn’t, keeps going.
Here’s the Jammer function:
const [jammerState, setJammerState] = useState(false);
const toggleJammer = () => {
setJammerState(true);
};
const jam = () => {
const start = Date.now();
const end = start + 10000;
let i = 0;
while (Date.now() < end) {
console.log("Jamming " + i++);
}
};
useEffect(() => {
if (!jammerState) return;
// Use requestAnimationFrame to ensure the render is painted before blocking
const rafId = requestAnimationFrame(() => {
jam();
setJammerState(false);
});
return () => cancelAnimationFrame(rafId);
}, [jammerState]);
...
<TouchableOpacity
style={[
styles.jammerButton,
jammerState ? styles.jammerButtonOn : styles.jammerButtonOff
]}
onPress={toggleJammer}
activeOpacity={0.8}
>
<Text style={styles.jammerButtonText}>
{jammerState ? 'Jammer: On' : 'Jammer: Off'}
</Text>
</TouchableOpacity>I keep the useState and useEffect here for one reason: it makes the jamming visible on screen.
- I set the jammer state to
true. - The button reacts to it immediately.
useEffectruns and blocks the whole JS thread.- Ten seconds later (inside that same
useEffect!), the state flips back tofalse. - The UI finally catches up and reflects the change.
I recreated the Lynx Gallery example in React Native using this exact pattern. The goal here isn’t to match behavior perfectly, but to reset expectations.
If I block the JavaScript thread, the app should feel blocked.
And that’s exactly what happens. Buttons stop responding, JS-driven interactions stall, and anything scheduled on the JS thread pauses. In my case, the automatic list scrolling stops immediately, because it’s driven by timers running on JS.
What’s interesting is what doesn’t stop. I can still scroll the list manually. That interaction lives on the native side and doesn’t need JavaScript to keep moving. If you’ve worked with React Native long enough, this feels familiar.
There are obvious caveats. A React Native reimplementation of the Lynx Gallery isn’t identical. Some interactions behave differently, and a few details are rough around the edges. That’s fine. This is not a feature-by-feature comparison, and it’s not a performance benchmark.
All I want here is a clear mental reference point for what “blocking JavaScript” actually means in practice. With that baseline in place, we can now try the same idea in Lynx.
Extending the Gallery: BTS Jammer
With that React Native baseline in mind, let’s go back to Lynx.
Up to this point, what we have in Lynx is still just the Gallery example as-is. It works, it scrolls, and nothing really breaks. Both scrollbars move smoothly, the UI feels responsive, and there’s no obvious moment where the threading model makes itself known. Let’s change that.
I make a few very deliberate, slightly ugly changes whose sole purpose is to make differences visible.
First, I label everything. The scrollbars are marked MTS (Main Thread Script) and BTS (Background Thread Script). On top of that, I move the main-thread scrollbar to the left side of the screen. This breaks every scrollbar convention you can think of, but that’s fine. It’s not about building a great user experience.
Once I start breaking things, I don’t want to guess which one stops moving. Ideally, only one of them does, and ideally, it’s the background one.
Speaking of breaking things, I add a “Jammer” button. It’s almost identical to the one from the React Native example. Pressing it blocks the background thread on purpose, using the same Jammer idea as before.
With those changes in place, the example stops being polite. Now it’s set up to actually show what happens when one thread gets into trouble.
None of this is unexpected. It’s just awesome to see in a small, controlled example.
When I jam the JS like this, I jam the default thread (which is background thread script) and the following things happen:
- The background-thread scrollbar path stops updating; expected, BTS is jammed.
- The button that triggered the Jammer becomes unresponsive, and I can’t “like” things anymore. That’s expected again, by default every client JS code is bound to background.
- The main-thread scrollbar path keeps working. This is the payoff we were expecting, and proof that JS code bound to main thread script can still function normally.
- The list keeps auto-scrolling; the mechanism behind the invoke method must “fire and forget” this action on the UI thread.
useEffect(() => {
galleryRef.current
?.invoke({
method: "autoScroll",
params: {
rate: "60",
start: true,
},
}).exec();
}, []);So the UI keeps moving, even while the app’s background JS is pinned. This is the separation the docs are pointing at, but the Jammer is what makes it obvious.
Why this happens
Lynx’s docs describe the runtime as two threads with different responsibilities and different engines. This is why Lynx frames main thread scripts as something you use selectively, mostly for gesture handling and smooth animations.
The practical takeaway from the Jammer is simple:
- Blocking the background thread blocks the parts of the app that depend on background JS.
- It does not have to block the parts of the app that are wired to the main thread.
This article doesn’t prove that Lynx is faster, better, or magically immune to performance problems. What it does is make one core idea tangible. You can block JavaScript on purpose and still keep meaningful parts of the UI alive, as long as they’re wired to the right thread.
That distinction is easy to read about and surprisingly hard to feel.
API aside
One thing I really like about Lynx is how explicit the threading model is in the code. You don’t have to infer it from comments or naming conventions. It shows up directly in the APIs.
That starts with the imports and refs:
import { MainThread, type ScrollEvent } from "@lynx-js/types";
import { useEffect, useMainThreadRef, useRef } from "@lynx-js/react";
import type { NodesRef } from "@lynx-js/types";
[...]
const galleryRef = useRef<NodesRef>(null);
const scrollbarRef = useRef<NiceScrollbarRef>(null);
const scrollbarRefMTS = useMainThreadRef<MainThread.Element>(null);The moment you touch the main thread, the types make it obvious. Even refs are different. A ref used on the background thread is not the same thing as a ref used on the main thread, and the type system makes sure you don’t mix them up by accident.
The same separation is visible on the list itself:
<list
ref={galleryRef}
bindscroll={onScroll}
main-thread:bindscroll={onScrollMTS}
>This is the entire setup. The same list emits scroll events through two paths. One goes through the background thread. The other is handled on the main thread.
Each path has its own handler. The background-thread version is just a regular function.
The main-thread version looks similar, but it includes one important line:
const onScrollMTS = (event: ScrollEvent) => {
"main thread";
adjustScrollbarMTS(
event.detail.scrollTop,
event.detail.scrollHeight,
scrollbarRefMTS
);
};That "main thread" directive is what tells Lynx where this code runs. Between the imports, the refs, the bindings, and the directive itself, the threading model isn’t hidden or implicit. It’s part of the surface area you work with every day.
That’s all for today. More Lynx experiments coming soon.

Learn more about Cross-Platform
Here's everything we published recently on this topic.












