June 29, 2026 by Olivier Goffart

Slint and the Node.js Event Loop Blog RSS


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 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, 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 on Linux, kqueue on BSD and macOS, I/O completion ports on Windows.

Two Loops, One Thread

A Slint application using the Node binding has two event loops sharing one thread. Node drives libuv, which runs the timers and I/O that JavaScript schedules: network, files, DNS, child processes. Slint drives its windowing backend, 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, 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() 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()) from a future on Slint's own loop. spawn_local gives that future a Waker 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 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 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, and the Slint Node.js guide has the rest of the API surface.


Comments

Slint is a Rust-based toolkit for creating reactive and fluent user interfaces across a range of targets, from embedded devices with limited resources to powerful mobile devices and desktop machines. Supporting Android, Windows, Mac, Linux, and bare-metal systems, Slint features an easy-to-learn domain-specific language (DSL) that compiles into native code, optimizing for the target device's capabilities. It facilitates collaboration between designers and developers on shared projects and supports business logic development in Rust, C++, JavaScript, or Python.