Slint and the Node.js Event Loop

ogoffart1 pts0 comments

Slint and the Node.js Event Loop — Slint Blog

Search -->

June 29, 2026 by Olivier Goffart

Slint and the Node.js Event Loop

Slint is a toolkit for building cross-platform UIs.<br>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.<br>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:<br>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.<br>Slint 1.17 fixes that on Linux and macOS.<br>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.<br>You write something like:

import * as slint from "slint-ui";

const ui = slint.loadFile(new URL("app.slint", import.meta.url));<br>const window = new ui.MainWindow();<br>window.show();<br>await slint.runEventLoop();

runEventLoop returns a Promise that resolves when the user closes the last window or the app calls quit().<br>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.<br>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.<br>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.<br>Each turn it waits for the next event, dispatches a handler, and goes back to waiting.<br>In pseudo-code:

loop {<br>let timeout = time_until_next_timer();<br>// Block until an event arrives or until a timer expires.<br>let events = wait_for_events(timeout);

for event in events {<br>dispatch(event); // deliver input, resize, ... to the UI

fire_due_timers(); // run expired timer callbacks<br>run_posted_callbacks(); // callbacks posted from other threads or async tasks<br>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.<br>Node drives libuv, which runs the timers and I/O that JavaScript schedules: network, files, DNS, child processes.<br>Slint drives its windowing backend, winit,<br>which talks to the platform's window system (X11, Wayland, AppKit, Win32)<br>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,<br>and those callbacks call into JavaScript, which must run on Node's main thread.<br>Also, on macOS the GUI can only be driven from the main thread, which Node already occupies.<br>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.<br>libuv runs between ticks, so JavaScript timers and I/O work again.<br>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.<br>The key is to drain Slint's loop at the right point in each libuv iteration:

┌── one libuv iteration ───────────────────────────┐<br>│ 1. update cached clock │<br>│ 2. run due timers │<br>│ 3. run pending callbacks │<br>│ 4. run prepare hooks ◄── we install ours here │<br>│ 5. poll for I/O, sleeping up to │<br>│ uv_backend_timeout() ms (normally blocks) │<br>│ 6. run check hooks │<br>│ 7. run close callbacks │<br>└──────────────────────────────────────────────────┘

libuv exposes uv_prepare_t, a handle whose callback fires on every iteration,<br>after timers have run but before the I/O poll.<br>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.<br>fn prepare_callback() {<br>// How long libuv may sleep before its next timer or I/O is due.<br>let timeout = uv_backend_timeout(uv_loop);<br>// Run Slint's event loop: handle a pending windowing event (input, resize)<br>// or other Slint event (timer, posted callback), else block up to `timeout`<br>// waiting for...

slint event loop node thread libuv

Related Articles