Cached at:
06/30/26, 03:39 PM
# Slint and the Node.js Event Loop
Source: [https://slint.dev/blog/slint-and-the-nodejs-event-loop](https://slint.dev/blog/slint-and-the-nodejs-event-loop)
Slint is a toolkit for building cross\-platform UIs\. Its core is written in Rust, but the same`\.slint`markup compiles into idiomatic APIs for Rust, C\+\+, JavaScript, TypeScript, and Python, so Slint can live in whichever language fits the application\.
JavaScript and TypeScript suit a particular kind of desktop app well: code that coordinates input, network calls, files, and a database rather than crunching numbers\. We want Slint to be a serious option for those apps, a lighter alternative to Electron: direct GPU access, no browser\.
The Node\.js binding has been around for a while, but until recently it had a sharp edge: the UI thread woke up every 16 milliseconds whether it had work to do or not, burning CPU and battery even when idle, and UI events could land up to 16 ms late\.[Slint 1\.17](https://slint.dev/blog/slint-1.17-released)fixes that on Linux and macOS\. This post is how we did it, and why Windows, Deno, and Bun are still on our list\.
## Using Slint from Node\.js
`slint\-ui`is a regular npm package\. You write something like:
```
import * as slint from "slint-ui";
const ui = slint.loadFile(new URL("app.slint", import.meta.url));
const window = new ui.MainWindow();
window.show();
await slint.runEventLoop();
```
`runEventLoop`returns a Promise that resolves when the user closes the last window or the app calls`quit\(\)`\. Under the hood it crosses into Rust via[napi\-rs](https://napi.rs/), and that's where the trouble starts: once Rust takes over the thread, Node's timers and I/O stop until it hands the thread back\. To see why, we need to back up and talk about event loops\.
## What's an Event Loop?
An event loop drives any program that spends its life waiting for things to happen rather than running start to finish: a click, a network packet, a timer firing\. It is essentially an infinite loop that sits at the bottom of the call stack for the whole life of the program and only returns when it exits\. Each turn it waits for the next event, dispatches a handler, and goes back to waiting\. In pseudo\-code:
```
loop {
let timeout = time_until_next_timer();
// Block until an event arrives or until a timer expires.
let events = wait_for_events(timeout);
for event in events {
dispatch(event); // deliver input, resize, ... to the UI
}
fire_due_timers(); // run expired timer callbacks
run_posted_callbacks(); // callbacks posted from other threads or async tasks
render_frame(); // repaint windows whose contents changed
}
```
The blocking`wait\_for\_events`maps onto one OS primitive:[epoll](https://en.wikipedia.org/wiki/Epoll)on Linux,[kqueue](https://en.wikipedia.org/wiki/Kqueue)on BSD and macOS,[I/O completion ports](https://en.wikipedia.org/wiki/Input/output_completion_port)on Windows\.
## Two Loops, One Thread
A Slint application using the Node binding has two event loops sharing one thread\. Node drives[libuv](https://libuv.org/), which runs the timers and I/O that JavaScript schedules: network, files, DNS, child processes\. Slint drives its windowing backend,[winit](https://github.com/rust-windowing/winit), which talks to the platform's window system \(X11, Wayland, AppKit, Win32\) and surfaces keyboard, pointer, resize, and expose events\.
They have to share the same thread because Slint properties are accessed both during rendering and from the callbacks that GUI events fire, and those callbacks call into JavaScript, which must run on Node's main thread\. Also, on macOS the GUI can only be driven from the main thread, which Node already occupies\. That rules out the obvious "just put Slint's loop on a worker" workaround\.
## The 16 ms Tick
The simplest thing that works on any runtime is a`setInterval\(16\)`that calls into Rust, which runs one non blocking iteration of Slint's loop and returns\. libuv runs between ticks, so JavaScript timers and I/O work again\. But idle CPU is wasted, the process never sleeps, and every JavaScript timer is up to 16 ms late\.
## The Prepare Hook
In Slint 1\.17, on Linux and macOS, we replaced the tick with a real libuv integration\. The key is to drain Slint's loop at the right point in each libuv iteration:
```
┌── one libuv iteration ───────────────────────────┐
│ 1. update cached clock │
│ 2. run due timers │
│ 3. run pending callbacks │
│ 4. run prepare hooks ◄── we install ours here │
│ 5. poll for I/O, sleeping up to │
│ uv_backend_timeout() ms (normally blocks) │
│ 6. run check hooks │
│ 7. run close callbacks │
└──────────────────────────────────────────────────┘
```
libuv exposes[`uv\_prepare\_t`](https://docs.libuv.org/en/v1.x/prepare.html), a handle whose callback fires on every iteration, after timers have run but before the I/O poll\. That is exactly where we want to be: late enough that JavaScript timer callbacks have already fired, early enough that the poll's sleep budget reflects whatever we just did\.
```
// Slint's libuv prepare callback -- edited for brevity.
fn prepare_callback() {
// How long libuv may sleep before its next timer or I/O is due.
let timeout = uv_backend_timeout(uv_loop);
// Run Slint's event loop: handle a pending windowing event (input, resize)
// or other Slint event (timer, posted callback), else block up to `timeout`
// waiting for one.
process_slint_events_with_timeout(timeout);
// We already did the waiting here. The trick: signal libuv so its own
// I/O poll (step 5) returns at once instead of blocking again.
}
```
[`uv\_backend\_timeout\(\)`](https://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_timeout)is libuv's answer to "how long is it safe to sleep before something else needs attention", so Slint sleeps exactly that long unless a UI event wakes it sooner\.
## Waking Slint When libuv Has I/O
The prepare hook covers one direction, the other matters too\. When libuv has I/O ready, Slint needs to notice quickly and hand control back\. So we watch libuv's backend fd \([`uv\_backend\_fd\(\)`](https://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_fd)\) from a future on Slint's own loop\.`spawn\_local`gives that future a[`Waker`](https://doc.rust-lang.org/std/task/struct.Waker.html)whose`wake\(\)`wakes Slint's event loop, and async\-io calls it as soon as the fd is readable, so the`process\_slint\_events\_with\_timeout`blocked in the prepare hook returns and hands control back to libuv:
```
// The single epoll/kqueue fd behind the libuv loop; readable when any I/O is ready.
let backend_fd = uv_backend_fd(uv_loop);
// Wrap it so async-io's reactor watches it and wakes our future on readability.
let async_fd = async_io::Async::new_nonblocking(backend_fd)?;
slint::spawn_local(async move {
// We don't read the fd; being woken is the point.
while async_fd.readable().await.is_ok() {}
})?;
```
Once that returns control to the prepare callback, it signals libuv via[`uv\_async\_send`](https://docs.libuv.org/en/v1.x/async.html#c.uv_async_send)so its next iteration fires our callback\.
## Windows
Windows uses I/O completion ports rather than a single waitable fd, so the Unix prepare\-hook approach doesn't translate directly\. The plumbing exists inside libuv but isn't part of its public API, and Node doesn't re\-expose it\.
Electron[patches libuv](https://github.com/electron/electron/blob/main/patches/node/feat_add_uv_loop_interrupt_on_io_change_option_to_uv_loop_configure.patch)to expose the completion\-port handle, but that patch is not upstream\. libuv 2\.x will fix this properly, but not soon, and Node\.js will adopt it later still\.
So Windows is still on the 16 ms tick\. The most promising fix is a dedicated`node\-slint`runner which ships its own patched libuv, similar to what Electron does\.
## Deno and Bun
Deno doesn't use libuv at all \(it's Rust on top of tokio\), and Bun is its own runtime\. Neither offers the hook we use for Node, so the plan is to invert ownership: a runner where Slint's loop is primary and the runtime's futures schedule onto it via`slint::spawn\_local`\.
Until then, both still run the same`\.node`binary as Node: we resolve the libuv symbols at runtime via`libloading`, so a host that doesn't expose them just fall back to the 16 ms tick instead of failing to load\.
## What You Get Today
If you`npm install slint\-ui`and run on Linux or macOS, you get the libuv integration: UI input is dispatched immediately, idle apps sleep, and CPU usage drops to zero when nothing's happening\. On Windows, Deno, and Bun the binding falls back to the 16 ms tick\. It still works, it's just not as nice\.
We're still working on those three\. If you've wanted a desktop UI from JavaScript without bundling a browser, the npm package is ready today\. The full implementation is in[api/node/rust/uv\_event\_loop\.rs](https://github.com/slint-ui/slint/blob/master/api/node/rust/uv_event_loop.rs), and the[Slint Node\.js guide](https://docs.slint.dev/latest/docs/node/)has the rest of the API surface\.