Remote display
This is the case the architecture is categorically better at, and the one nothing else on the page has told you about.
A react-x11 app draws over the wire. It does not render pixels and ship them; it sends X11 drawing requests — rectangles, glyph runs, composites — and the X server on the far end turns them into pixels. So "running the app somewhere else" is not a feature bolted on top. It is what the protocol is, and the program does not know the difference.
ssh -X buildbox
npm start # the window opens on your desktop
Nothing in the app changes. No VNC, no browser, no streaming daemon, no port to forward, no second copy of the UI.
This is the X11 backend, and it is the reason X11 stays the flagship
target rather than becoming the legacy one: the Cocoa backend has no wire at
all, so nothing on this page applies to it. That matters in one specific
case — an app running on a Mac and drawing somewhere else. $DISPLAY in
the environment does not choose the backend there; backend: 'auto' sees
darwin and picks Cocoa, and the window opens on the Mac. Say which you want:
REACT_X11_BACKEND=x11 DISPLAY=thin-client:0 npm start
createRoot({ display: … }) or createRoot({ stream: … }) also settle it —
naming an X endpoint in code is choosing X11, because an ignored
connection option would be worse than either answer. An app running on Linux
and drawing to a Mac's XQuartz is not affected: both ends are X11 already.
What it costs, measured
The honest comparison is with pixel streaming (VNC, RDP, a
--remote-debugging browser), because that is what people reach for
instead. The trade is bytes-per-frame against round-trips-per-interaction.
From scripts/bench/baseline.json, which measures against node-x11's
in-process server — so these are exact protocol costs, not estimates:
| scenario | requests | bytes out | replies | stalls |
|---|---|---|---|---|
| mount: a window, 40 boxes + labels | 110 | 4.2 kB | 32 | 0 |
| 50 filled rounded boxes | 122 | 6.6 kB | 7 | 0 |
| a paragraph of text | 39 | 6.0 kB | 7 | 0 |
| 10 wheel notches over 500 rows | 224 | 11.2 kB | 22 | 4 |
A whole window appears for about four kilobytes. A single 1280×800 frame of VNC, even well compressed, is tens to hundreds of kilobytes, and it is sent again every time anything moves. Scrolling a 500-row table ten notches costs 11 kB here because only the damaged strip is redrawn (the scroll-blit path, issue #138); the same scroll under pixel streaming is ten full frames.
What react-x11 pays instead is latency sensitivity. Bandwidth is not the scarce resource on a link like this — round trips are. A request that waits for a reply stalls the pipeline for a full RTT, so on a 60 ms link every reply is 60 ms of nothing.
The last two columns are how that is watched, and the difference between
them is the whole point. replies counts requests that carry an answer;
stalls counts the ones whose reply arrived with nothing pipelined behind
them — the request was the last thing sent, so the connection really did
sit idle for a round trip. A pipelined reply costs a reply and no time: the
mount's 32 are almost all InternAtom, fired off together while the window
is built, and it stalls on none of them. (The bench's own settle() fences
are a fixed floor in that column, so read growth rather than the absolute
number.)
So the rule — "avoid round trips", rule 4 in
AGENTS.md — is really "avoid blocking
round trips", which is why atoms, geometry and glyph pages are cached rather
than re-asked, and why stalls is the column to watch on a slow link.
npm run bench -- --hotspots attributes them, request by request.
Rules of thumb, from the shape of the protocol rather than from a benchmark we have not run:
- Under ~20 ms RTT (a LAN, a nearby VPS): indistinguishable from local.
- 20–80 ms (a continent away): typing and scrolling stay responsive; window mapping and font setup have a visible beat.
- Above ~150 ms or on a lossy link: X11's chattiness starts to show, and a pixel-streaming protocol designed for high latency will feel better. This is not a react-x11 limitation, it is X11's, and it is the honest place to say so.
unverified: those bands are reasoning from the request/reply counts above,
not measurements over a shaped link. Measuring them properly means tc netem between two hosts; if you do it, the numbers belong in this table.
ssh -X and ssh -Y
-X runs your app as an untrusted X client. -Y runs it trusted, which
turns the restrictions off. They are not "the one that works" and "the one
that does not":
-X (untrusted) | -Y (trusted) | |
|---|---|---|
| read other windows' pixels | no | yes |
| grab the keyboard, record input | no | yes |
| take the clipboard from other apps | restricted | yes |
| your app's own windows | fine | fine |
Prefer -X. react-x11 should work under it — it draws into windows it
created, which is what untrusted mode is for. If something does not, that is
a bug worth filing rather than a reason to reach for -Y. Most toolkits'
documentation says "use -Y if -X misbehaves"; that advice hands every
program on the remote host the ability to read your screen and log your
keystrokes.
The failure that looks like a crash
An untrusted -X cookie expires. OpenSSH's ForwardX11Timeout defaults
to 20 minutes; after that the connection is refused and your app dies
mid-session with a connection reset — no warning, no message that mentions
X11, and it happens long enough after startup that nobody connects the two.
# in ~/.ssh/config
Host buildbox
ForwardX11 yes
ForwardX11Trusted no
ForwardX11Timeout 8h # or 0 for no expiry
If a long-running app dies after roughly twenty minutes and
onDisconnect(reason) reports 'error', this is the first thing to check.
react-x11 tells you it happened, and nothing more —
a reconnect is not a reconnect:
every window id, pixmap, glyph set and font died with the connection.
DISPLAY=host:0 — and why not
The other way to reach a remote display is to point DISPLAY straight at it
and open port 6000. Do not:
- The connection is plaintext. Every keystroke, every glyph you draw and the authentication cookie itself cross the network in the clear.
- Making it work usually means
xhost +, which turns off access control for everyone, orxhost +host, which trusts every user on that host. - Most X servers ship with TCP listening disabled now, so it takes deliberate work to open the hole.
ssh -X gives you the same thing, encrypted, authenticated per-session, and
with no listening port. Use it. If you genuinely need a raw display
connection — a lab network, a kiosk — carry the cookie explicitly with
xauth and never with xhost (see security.md).
The other X servers
"An X server" is broader than "a Linux desktop":
| where | what runs |
|---|---|
| Linux desktop | X.Org, or Xwayland — see below |
| macOS | XQuartz. ssh -X from Terminal starts it for you |
| Windows | VcXsrv, Xming, or an X server inside WSLg |
| headless CI | Xvfb :99 -screen 0 1280x800x24 |
| a disposable screen | Xephyr :10 -screen 1200x800 — a window on your desktop that is a display. What the window-manager example needs |
| a session you can reattach to | Xvnc / TigerVNC, or Xpra, which is X11-native and can detach and reattach a running app |
| no display at all | node-x11's in-process server — testing.md |
Xwayland: yes. On a Wayland desktop, X11 clients run under Xwayland, and
react-x11 is an ordinary X11 client there. Everything in this documentation
applies. Some window-manager interactions are thinner (Xwayland brokers what
the compositor is willing to expose), and WM_TRANSIENT_FOR and the
_NET_WM_STATE work are among the things that survive the trip — see
elements.md.
Native Wayland: no, and the reason is not effort. Wayland has no drawing protocol. A Wayland client allocates a buffer, renders pixels into it itself, and hands the compositor a handle — there is nothing to send over a wire, and no server-side rendering to ask for. The entire premise of this renderer, that drawing crosses the connection, does not exist there. A Wayland port would not be a port; it would be a different program that rasterizes locally, and at that point the interesting property is gone.
XQuartz notes, since it is the most common macOS path:
- The window manager is quartz-wm, which has the thinnest EWMH support in
the stack. It does not advertise
_NET_WM_STATE_ABOVE, soalwaysOnTopfalls back to Apple-WM window levels (elements.md). - Put
/opt/X11/binfirst onPATH. Font family resolution shells out tofc-match, and Homebrew's fontconfig — which is usually found first — ships no macOS system-font aliases, sosans-serifresolves to a CJK face. Issue #86.
Making it feel better
Everything the renderer does to be cheap locally pays double here, because each saved request is a saved trip across the link:
- Keep repaints bounded.
REACT_X11_DEBUG_PAINT=fullwarns, with a reason and a stack, on any silent full-window repaint — debugging.md. A full repaint is the difference between 4 kB and 40 on every frame. REACT_X11_TRACE=summaryprints the request and byte counts for a live app, so you can see what a given interaction actually costs on the wire before blaming the link.ssh -Ccompresses. X11 traffic is highly compressible — repeated drawing requests, text, glyph data — and on a slow link it is usually worth it.- Prefer one long-lived connection to many short ones: connection setup is several round trips of its own, plus the font and atom warm-up.
- Drags across applications are round-trip heavy, because finding the
foreign window under the pointer means asking the server, per pointer
position. Dragging within the app costs nothing extra — it never
touches the wire — and a drop target that answers with
e.freeze()stops the source asking while the pointer stays inside it. See drag-and-drop.md.