Skip to content

Wayland (the Wayland protocol)

The modern Linux display protocol: an asynchronous, object-oriented wire protocol where a client connects to a compositor over a Unix socket, binds protocol objects advertised by a wl_registry, and builds a window out of a tree of those objects — there is no "create window" syscall, only a negotiated handshake of wl_surface + role + buffer. This is the layer the Wayland backends of GTK 4, winit, SDL3, and Smithay/libdecor all reduce to on Linux.

Last reviewed: June 9, 2026

FieldValue
Native APIThe Wayland protocol — an async object/request/event wire protocol (wl_displaywl_registry → bound globals), spoken via libwayland-client
Library / frameworklibwayland-client (-lwayland-client) for the core wire ABI; per-window roles from wayland-protocols (xdg-shell, …) generated by wayland-scanner; libxkbcommon, libdecor
Header / protocol source<wayland-client.h> (core ABI) + scanner-generated headers from the protocol XML (wayland.xml, xdg-shell.xml); the spec is the Protocol appendix
Window handle typeA wl_surface* given the xdg_toplevel role (via xdg_surface); it is a protocol object id, not an OS window handle
Event-loop primitiveThe wl_display connection fd, drained with wl_display_dispatch (or the thread-safe wl_display_prepare_read / wl_display_read_events pair) — a readiness loop
Coordinate unitLogical (surface-local) coordinates; fractional scale arrives as an integer in 1/120ths via wp_fractional_scale_v1
Decoration ownerClient-side by default (CSD) — the compositor may advertise xdg-decoration for optional server-side decorations (SSD), but it is only a hint
Example./example/app.d

NOTE

The protocol is the API. Unlike AppKit or the Android NDK — where a C/Objective-C library wraps an OS that owns the windows — on Wayland there is no privileged OS window object. The compositor and client are two peers exchanging messages over a socket, and every capability (surfaces, input seats, decorations, scaling, clipboard) is a protocol object the client must bind from the wl_registry and drive by hand. libwayland-client is just the marshaller for that socket; there is no hidden window manager inside it.


What it is

Wayland is a wire protocol, not a widget library. A client opens the compositor's Unix socket (named by $WAYLAND_DISPLAY), and from then on every interaction is an asynchronous request (client → compositor) or event (compositor → client) on a numbered protocol object. The root object is the wl_display; from it the client obtains the registry, and the registry is how everything else is discovered. The core protocol describes the registry verbatim:

The singleton global registry object. The server has a number of global objects that are available to all clients. These objects typically represent an actual object in the server (for example, an input device) or they are singleton objects that provide extension functionality.

wl_registry, the core Wayland protocol

The unit of pixels-on-screen is the wl_surface, and its description fixes the mental model the rest of this survey builds on:

A surface is a rectangular area that is displayed on the screen. It has a location, size, pixel contents, and may have seat-specific input focus.

wl_surface, the core Wayland protocol

Two facts dominate every design choice below. First, Wayland is asynchronous and object-oriented: a request does not return a value, it allocates a new object id whose events arrive later, so the client is a state machine reacting to events, never a sequence of blocking calls. Second, a bare wl_surface is not a window — it is an anonymous rectangle. To become a desktop window it needs a role (xdg_toplevel, via xdg_surface), and to become visible it needs a committed buffer after the compositor's first configure (the no-buffer-no-window rule). The Wayland Book frames the bootstrap that precedes all of it: "the server offers a list of global objects", and "the wl_display::get_registry request can be used to bind an object ID to the wl_registry interface".

The example here does not hand-declare any of this. It reaches the protocol through D's ImportC: the shim ./example/c.c #includes the system <wayland-client.h>, so the core ABI (wl_display, wl_proxy, wl_proxy_marshal_flags) and the generated core-protocol symbols — the exported wl_registry_interface type table and the wl_registry_listener struct — come straight from libwayland-client. Hand-writing that interface table is the fragile part; ImportC supplies it for free.

NOTE

The header marks the bootstrap functions wl_display_get_registry and wl_registry_add_listener as static inline, so ImportC cannot import them. The example calls the underlying wl_proxy_marshal_flags / wl_proxy_add_listener directly — exactly what those inlines expand to — and is documented as such in ./example/app.d.


The minimal program

The example, ./example/app.d, is the irreducible Wayland bootstrap: connect, get the registry, and enumerate the globals the compositor advertises. It deliberately stops short of a visible window, and that stopping point is itself the headline finding (below). The sequence:

  1. Connect. wl_display_connect(null) opens the socket named by $WAYLAND_DISPLAY. With no compositor it returns null, so the program prints SKIP: and exits 0 — the host-capability gating the research-doc guidelines require, so CI on a headless runner stays green. scope (exit) wl_display_disconnect(dpy) balances it.
  2. Get the registry. The example hand-expands the static inline wl_display_get_registry: it calls wl_proxy_marshal_flags(displayProxy, WL_DISPLAY_GET_REGISTRY, &wl_registry_interface, …), naming the new object's interface with the real exported wl_registry_interface table. This is the wl_display.get_registry request — the single request every Wayland client must make first.
  3. Listen and round-trip. A static wl_registry_listener wires onGlobal/onGlobalRemove; wl_proxy_add_listener (the expansion of wl_registry_add_listener) installs it; wl_display_roundtrip(dpy) blocks until the compositor has replied, firing one global event per advertised interface. The callback prints each global's name, interface string, and version, and counts them.

That is the genuinely minimal part. A real window needs much more, and the gap is the finding:

IMPORTANT

A window is a tree of objects, normally built by codegen. To turn a wl_surface into a visible toplevel the client must additionally bind xdg_wm_base (from xdg-shell), create an xdg_surface and an xdg_toplevel, commit with no buffer, wait for xdg_surface.configure, ack it, and only then attach a wl_shm (or dma-buf) buffer and commit again (the no-buffer-no-window handshake). That protocol glue is normally generated by wayland-scanner from the protocol XML. The comparison the example's header draws is the lesson in one line: an X11 window opener is ~80 lines of direct calls; the equivalent on Wayland effectively requires code generation — the protocol trades X11's "map a window now" simplicity for an extensible, asynchronously-negotiated object graph.

Because the example never attaches a buffer, it can never show a window — and that is correct: showing one without the full handshake is impossible by construction, not an omission. The survey below walks the complete handshake that GTK 4, SDL3, and winit each automate.


Window creation & lifecycle

There is no synchronous window-creation call. A toplevel window is assembled from four cooperating objects and a negotiated startup:

  1. wl_compositor.create_surface → a bare wl_surface (the core protocol calls wl_compositor the object "in charge of combining the contents of multiple surfaces into one displayable output").
  2. xdg_wm_base.get_xdg_surface(surface) → an xdg_surface (the desktop-window adapter). xdg-shell describes xdg_wm_base as "exposed as a global object enabling clients to turn their wl_surfaces into windows in a desktop environment."
  3. xdg_surface.get_toplevel → an xdg_toplevel, the role that makes the surface an application window (title, app-id, min/max, parent, fullscreen).
  4. The mandatory handshake, spelled out by the spec:

Creating an xdg_surface from a wl_surface which has a buffer attached or committed is a client error, and any attempts by a client to attach or manipulate a buffer prior to the first xdg_surface.configure call must also be treated as errors. … the client must perform an initial commit without any buffer attached. The compositor will reply with … an xdg_surface.configure event.

xdg_surface, the xdg-shell protocol

So the lifecycle is: create the objects → commit with no buffer → receive xdg_surface.configureack_configure → attach the first wl_shm/dma-buf buffer → wl_surface.commit → the window appears. This is the canonical no-buffer-no-window rule, and its consequence is that "create window" is asynchronous state, not a one-shot call: winit blocks in a round-trip until the surface is_configured() before its constructor returns, SDL3 spins on dispatch "until the surface gets a configure event", and Smithay SCTK draws its first frame inside the configure handler. The Wayland Book confirms the trap from the buffer side: attach + commit alone leaves you "confused when your buffer is not shown on-screen" because the surface still lacks a role.

Resize, maximize, fullscreen, and activation are not setters either — they arrive as xdg_toplevel.configure events carrying a suggested size and a state array, and the client redraws to match and acks. Closing is cooperative: the compositor sends xdg_toplevel.close (a request to close, which the app may veto), and the client tears down its objects.

WARNING

The client cannot place its own toplevel. There is no SetWindowPosition equivalent — the compositor owns placement, so a client that tries to position a top-level window finds the call is a silent no-op (recorded across SDL3 / GLFW). Window geometry committed without a buffer is also "silently dropped" on some compositors (Chromium Ozone). Model the lifecycle as explicit negotiated state from the start; do not retrofit a synchronous "create then show" API onto it.


Event loop & frame pacing

The loop primitive is the display connection file descriptor. Wayland is a readiness model exactly like the Linux epoll/poll reactors: the client gets an fd, waits for it to become readable, then drains and dispatches the queued events. wl_display_get_fd exists for precisely that purpose:

Return the file descriptor associated with a display so it can be integrated into the client's main loop.

wl_display_get_fd, the Wayland Client API

The simple loop is wl_display_dispatch, which the same reference describes:

Dispatch events on the default event queue. If the default event queue is empty, this function blocks until there are events to be read from the display fd.

wl_display_dispatch, the Wayland Client API

For any loop that also watches other fds (a timer, a socket, an async-io runtime's eventfd), wl_display_dispatch's internal blocking read is wrong — it can deadlock multiple readers. The thread-safe dance is wl_display_prepare_readpoll the fd yourself → wl_display_read_eventswl_display_dispatch_pending (with wl_display_flush before the wait to push queued requests out). The Client API is explicit that wl_display_prepare_read "must be called before reading from the file descriptor using wl_display_read_events", and that read_events results in "data available on the display file descriptor being read". This is the readiness-driven integration point every serious backend uses: winit wraps the Wayland fd as a calloop source, Smithay SCTK integrates it via calloop-wayland-source, and SDL3 hand-rolls the prepare_read/flush/read_events/dispatch_pending sequence. wl_display_roundtrip ("blocks until the server has processed all currently issued requests") is the synchronous escape hatch the example uses for one-shot enumeration.

Frame pacing is wl_surface.frame callbacks (frame-callback / vsync) — there is no separate display-link thread as on macOS. Before committing a frame the client requests wl_surface.frame, receiving a one-shot callback object; the compositor fires it done when the surface will next be presented, and the client draws then, re-requesting a callback each frame for continuous animation. This both paces to vsync and naturally throttles to zero when the surface is occluded (the callback simply stops firing). GTK 4 freezes its per-surface GdkFrameClock while a frame callback is outstanding so the client "never out-runs the compositor"; Qt 6 runs a dedicated second event thread so frame callbacks are not starved; GLFW gates its EGL swap on the callback so a swap on a hidden surface does not block forever.


Input

Input is delivered through a wl_seat — the abstraction of one set of keyboard/pointer/touch devices — bound from the registry. The seat advertises its capabilities, and the client obtains wl_keyboard, wl_pointer, and wl_touch accordingly. Focus is per-surface: enter/leave events tell each device which surface currently has it.

Keyboard. Wayland delivers raw evdev keycodes, not symbols, plus an xkb keymap the compositor sends over a file descriptor. The client maps codes to keysyms itself with libxkbcommon — this is the scancode / keysym / virtual-key split, resolved client-side. The Wayland Book notes the keymap "is transferred over file descriptors" and that "XKB keymaps are the only format which the server is likely to send."

WARNING

Two recurring keyboard gotchas. (1) wl_keyboard keycodes are raw evdev codes that need a +8 offset before every xkb call (the X11 keycode base) — every Linux backend applies it (winit, SDL3, Smithay SCTK). (2) Key repeat is the client's job. Per the Wayland Book, "In Wayland, the client is responsible for implementing 'key repeat'" — the compositor only sends a repeat_info event carrying the rate and delay; the client must run its own timer to synthesize repeated key events. A toolkit that forgets this has keys that do not auto-repeat.

Pointer. wl_pointer delivers enter/leave/motion (absolute, accelerated, surface-local) and button, batched between wl_pointer.frame boundaries. Scrolling uses high-resolution axis_value120, which reports wheel motion in 1/120 of a logical detent — the 120-per-detent convention shared with Win32; the client accumulates sub-120 deltas and emits one notch per 120 units, carrying the remainder. Relative/raw motion for first-person cameras is a separate source — zwp_relative_pointer_v1 paired with zwp_pointer_constraints_v1 for lock/confine — exactly the raw-vs-accelerated split; GTK 4 binds neither (deliberately, as it is not a game toolkit).

IME / text input. Composition (pre-edit / composition) is the zwp_text_input_v3 protocol — a separate event stream of pre-edit (provisional) vs commit (final) text. It is bound by SDL3, winit, and Qt 6; notably GTK 4's GDK does not bind it (GTK does IME in a layer above GDK).


Coordinates & scaling

Wayland's native unit is the logical, surface-local coordinate (logical vs physical). HiDPI is negotiated per-surface, and the modern mechanism is wp_fractional_scale_v1 paired with wp_viewporter. The fractional-scale protocol sends the scale factor as an integer numerator over 120:

The sent scale is the numerator of a fraction with a denominator of 120.

wp_fractional_scale_v1.preferred_scale, the fractional-scale protocol

So 120 means 1.0×, 180 means 1.5×, 90 means 0.75× — the conversion scale = numerator / 120.0 documented in concepts § scale factor. The client renders its buffer at that fractional scale and then uses a wp_viewport to declare the surface's logical size independently of the buffer's pixel size; the viewporter protocol describes itself as "cropping and scaling the surface contents, effectively disconnecting the direct relationship between the buffer and the surface size." Without wp_fractional_scale_v1, clients fall back to the integer-only wl_surface.set_buffer_scale (1×, 2×, 3×). GTK 4 stores the raw 120ths in GdkFractionalScale; GLFW reports numerator / 120.f; Smithay SCTK never implements fractional scale at all (integer only — the cautionary tale).

WARNING

Created-at-wrong-scale. A surface has no scale until it enters an output (pre-wl_output v6) or receives its first preferred_scale, so it is created at 1× and rescaled on the first event (scale factor). Robust clients treat the first scale as a configure-driven event and re-lay-out; dragging a window between differently-scaled monitors simply re-fires preferred_scale, so Wayland — unlike X11's single global Xft.dpi — handles mixed-DPI multi-monitor natively.


Decorations & multi-window/popups

Decorations are client-side by default (CSD vs SSD). A wl_surface has no titlebar concept; absent any agreement, the xdg-decoration protocol states "clients continue to self-decorate as they see fit." The optional server-side path is a negotiation, not a switch (the decoration handshake): the client binds zxdg_decoration_manager_v1 only if the compositor advertises it, creates a zxdg_toplevel_decoration_v1, and requests a mode — but the request is a preference:

Set the toplevel surface decoration mode. This informs the compositor that the client prefers the provided decoration mode. … The compositor can decide not to use the client's mode and enforce a different mode instead.

zxdg_toplevel_decoration_v1, the xdg-decoration protocol

The client must obey the configure(mode) it gets back: if client_side, it draws the frame; if server_side, it draws nothing. Because GNOME/Mutter advertises no decoration manager at all, every cross-platform toolkit must carry a CSD path — most delegate to libdecor (SDL3), draw their own frame (GTK 4, winit's sctk-adwaita), or do a tiered fallback (GLFW, Smithay SCTK). Cursor imagery has its own optional global, wp_cursor_shape_v1, which offers "an alternative, optional way to set cursor images" using "enumerated cursors instead of a wl_surface" — letting the compositor render the themed cursor instead of the client uploading a wl_surface.

Multi-window is first-class: a client creates as many xdg_toplevels as it likes on the one wl_display connection. Popups are the override-redirect vs xdg_popup grab story: a transient surface is an xdg_popup parented to another surface and positioned by an xdg_positioner (anchor + gravity + constraint-adjustment), never by absolute screen coordinates. The xdg-shell protocol describes a popup as "a short-lived, temporary surface … menus, popovers, tooltips and other similar user interface concepts", and the compositor owns the grab: xdg_popup.grab(seat, serial) means "An explicit grab will be dismissed when the user dismisses the popup, or when the client destroys the xdg_popup." The client cannot enforce click-outside dismissal itself — the compositor does.


Clipboard & drag-and-drop

Both clipboard and drag-and-drop ride the wl_data_device_manager family, obtained per-seat as a wl_data_device. A copy is a wl_data_source the client offers (advertising MIME types); a paste arrives as a wl_data_offer the client reads by requesting a type and reading the data from a pipe fd the compositor provides. The model is serial-gated: a wl_data_device.set_selection must carry the input event serial that justifies it, so a client cannot silently steal the clipboard without a recent interaction. There is no X11-style INCR chunking — bulk transfer is just a pipe.

Drag-and-drop is the same machinery in motion: wl_data_device.start_drag(source, origin, icon, serial) begins a drag (again serial-gated), and the destination receives enter/motion/drop on its wl_data_device, reading the dropped data through the identical wl_data_offer → pipe path. A separate zwp_primary_selection_v1 protocol models the X11-style middle-click "primary selection" that coexists with the clipboard. Toolkits wrap all of this directly: there is no privileged clipboard server, only the data-device objects and the pipe fds.


What toolkits build on this

Every Wayland-capable toolkit in the survey bottoms out in the same registry-bind → xdg-shell → buffer-commit sequence this survey describes:

  • GTK 4 — the reference Wayland client. GDK's Wayland backend speaks the protocol natively, owns its own CSD (binding neither libdecor nor zxdg-decoration-v1, falling back to its own frame on wlroots), drives redraw through a per-surface GdkFrameClock gated on wl_surface.frame, and stores fractional scale as raw 120ths. It does not bind text-input-v3 (IME lives above GDK) or the pointer-constraints/relative-pointer protocols.
  • winit — wraps the Wayland fd as a calloop source (the readiness-reactor integration), blocks its constructor until is_configured(), draws its own Adwaita CSD via sctk-adwaita, and exposes pre_present_notify purely to schedule a wl_surface.frame. It owns its xkb state and applies the +8 keycode offset.
  • SDL3SDL_waylandwindow.c builds the surface and delegates CSD to libdecor; its pump hand-rolls the prepare_read/flush/read_events/dispatch_pending readiness dance; it accumulates axis_value120 per wl_pointer.frame; on Wayland it reports the surface in logical units.
  • Smithay/libdecor — Smithay's client toolkit (SCTK) is the Rust reference for the raw handshake: it draws the first frame inside WindowHandler::configure, wraps popups as Popup/XdgPositioner, holds xkb::Context/State behind mutexes, and exposes the decoration mode verbatim as WindowConfigure.decoration_mode. libdecor is the de-facto CSD library SDL3 and GLFW lean on.

Sources