Architecture
How Wren is put together, and the rules that keep it from breaking as it grows. For the overview, the build and the feature list, see the README.
The rules that keep it from breaking at scale
- One ownership tree. process → tab → navigation → page → (instances, DOM, requests, timers). Everything a page
starts is registered in its scope (
src/base/scope.odin); closing a tab or navigating isscope_close. Cleanups run newest first, children before parents, so nothing outlives what it points to. Scopes are main-thread only. - Handles, never pointers, cross boundaries.
Handle = generation << 32 | index, 0 is invalid. A destroyed or recycled slot fails to resolve. Late network events, timers and listener callbacks for dead objects are dropped, not delivered to reused memory. Pool objects never move (chunked), so^Tstays valid until removed. - Single UI thread, everything else posts. The network stack's threads, the image decoders, the layout and style threads
and the blocking pool only push events and
host.postback to the loop; every user-visible callback runs on the loop thread. The DOM and the Wasm instances of a page live on the UI thread (workers have their own store). - Mechanism and policy are separate.
netmoves bytes and never follows a redirect, stores a cookie or sends ambient credentials.fetchdecides, per hop, on the UI thread, with the document's Environment in hand. A guest, a module load and a navigation all go through the same code, so there is one place where "may this request happen" is answered. - Bounded everything, with a reason each. Per-host (6) and global (64) request limits in our own priority queues
(so waiting never counts against connect timeouts); reading pauses when 1 MB is queued for a request;
max_bodycaps a response; at most 20 redirects (the Fetch Standard's number); cookies: at most 180 per site and 3000 in total with LRU eviction; at most 1000 preflight cache entries; guest memory is capped; every host → guest call has a deadline (epoch interruption): a spinning script is stopped, the instance lives on. Every one of these numbers is a preference (about:config). - The guest cannot reach host memory. Every address + length pair of an import is checked by its generated trampoline against the guest's linear memory before our code runs; strings are copied out through caller-provided buffers.
- No recursion on hostile depth. Tree destruction, text extraction, searches and the HTML replay are iterative.
- Everything is configurable. Each package declares its preferences in a
settings.odin(config.Def: dotted lower-case name, description, type, bounds, unit) and reads them throughsettings_from_prefs; the shell registers every table (src/browser/settings.odin) andsrc/browser/prefs_test.odinchecks that each one is well formed. - State lives with what owns it. See below: what is one App's is not a global.
Where state lives
The aim is several Apps (browser.App: one browser, its profile, its loop, its windows) in one process, each on a thread
of its own, so that tests run side by side in one binary. An App runs on one thread, the one that runs its loop
(app_start's); its page thread works for it. So a global is allowed only for what it is, and
tools/globals_allowed.txt gives every global of src/browser, src/page, src/style, src/layout, src/media and
src/js its class (src/tools/test_all/lint_globals.odin fails on a new one, a gone one, or a class the code
contradicts):
- const: a table, written by the compiler or once (a
sync.Once) before anything reads it. Any thread reads it. - process: a resource there is one of whatever the number of Apps: the Wasm engine, a thread pool, a library of the system, the single-instance channel, an id unique in the process (atomic). Locked, atomic, or bound to a thread the note names.
- thread:
@(thread_local), and only then (the lint holds the class to the attribute both ways). In packages that have no App (page,style,layout), what is one App's is its thread's: the live pages, the navigables, the frames waiting to close (page/web.odin,page/frames.odin),page.the_pages. Inbrowser, what the thread that paints keeps (the compositor's layers, the planes, the canvas text's glyphs) andthe_app, the App of this thread. - test: a test's own.
There is no class for one App's state (nor one window's, tab's or page's): it is a field of App, Browser_Window.own,
Tab or Page, and the lint fails on a global that is none of the above.
What belongs to the App is a field of App (src/browser/main.odin), reached through the ^App the code is given.
A callback the App sets up carries it: a timer's or a watch's data, a job's struct (Setup_Job.app), a window
subclass's reference data (app_commands_windows.odin). the_app is what is left for code that has no ^App yet,
mostly hooks of other packages (page.Embedder_Hooks, the canvas' text engine), the painting code and the developer tools'
drawing: a thread of its own sees nil.
What the App's pages share is page.Pages (src/page/pages.odin, App.pages): the embedder's hooks, the image cache,
the script compile service, the style threads, the profile's stores (Cache API, file system, service workers, Web Locks,
storage buckets, storage access decisions), the message ports, the shared workers, the statistics. The page package reaches it
through page.the_pages, bound by page.bind to the threads that run the App's pages: its loop's (app_start), its page
thread's (page_thread.odin), a worker's (worker_main, its creator's), a compile thread's. A program that embeds pages
without the shell (src/tools/headless, the web tests, apiconformance) binds its own; a page test binds one with
test_pages(t).
What the Apps of the process share is set up by the first App to start and let go of by the last to end
(src/browser/process_share.odin: one atomic word, the Apps that use it or a busy mark while the first sets it up or the
last lets it go; whoever starts or ends meanwhile waits on it): the Wasm engine and its host functions, the bytecode cache,
the image decoders, the layout threads, the decoded web fonts, the crash session (page.process_shutdown). The first App's
preferences and profile configure them.
src/tools/e2e/two_apps.odin runs two Apps, each on a thread of its own, at once: each sees only its own tabs, pages,
storage, channel messages and frame, and nothing of either is left once both have stopped. The toolkit keeps the
interface state it draws with in one variable of the process (ext/gui_toolkit/ui/state.odin, g): until it is the
thread's (@(private, thread_local) g: ^State), the two Apps' own threads take turns between the test's meetings (what
they run besides runs at once); with that one change they run at once too.
Wasm engine
Wasmtime, the official C API release v49.0.3, downloaded by wrentool fetch wasmtime (version and SHA-256
pinned in the script) into build\wasmtime; the static wasmtime.lib is linked into the exe (no DLL; it adds about
12 MB). It needs ws2_32 advapi32 userenv ntdll shell32 ole32 bcrypt kernel32 and the C runtime, linked in as for every native library of Wren
(libcmt libucrt libvcruntime, the static C runtime that Odin's core:c/libc is: wren.exe needs no runtime DLL), declared in src/wasm/wasmtime.odin. Modules are compiled by Cranelift (or Winch,
wasm.wasmtime.compiler), kept in a memory cache and in an HMAC-authenticated disk cache of the profile
(wasm-cache), and every call from the host has a wall-clock deadline (wasm.max_call_ms, epoch interruption; with
wasm.wasmtime.fuel, also an instruction budget). src/tools/wasmbench compares the compiler configurations and
measures host calls.
The web API: one description, everything generated
Every Wasm import of the browser is described once, in api/*.widl (a dialect of Web IDL: modules, functions, typed
handles, enums, flags, structs, callbacks, raises, [Exposed=Window|Worker], documentation). src/tools/bindgen
generates from it, deterministically:
- the host side,
src/page/api_generated.odin: one trampoline per import, defined directly on the Wasmtime linker, which checks every address against the module's memory, converts strings (UTF-8 checked), handles (looked up by kind), enums, flags and structs, and calls the implementation (bindings_*.odin) with Odin values. The expected prototype of each implementation is written above its trampoline: a missing or mistyped one does not compile; - the Odin guest SDK,
sdk/odin/<package>/<module>.odin, thewrencollection (import "wren:dom"; distinct handle types,string/[]u8/Maybe,(T, Error)results,F/F_len/F_allocfor strings of unknown size); - the documentation,
docs/api/(with the ABI); - conformance tests:
examples/apiconformanceandsrc/tools/apiconformance/cases_generated.odin(bad addresses, bad UTF-8, dead handles, enums out of range, buffers too small, for every function).
build.bat regenerates what changed; test_all fails when a generated file is out of date (bindgen -check).
Imports take 64-bit addresses in a module whose memory 0 is memory64 (wasm.features.memory64).
A document names its modules with <script type="application/wasm" src="..." nonce="...">, any number of them. They
are fetched concurrently and run in document order, each in its own instance sharing the DOM. (The syntax is one
function, html.wasm_scripts, if it ever changes.)
JavaScript
JavaScript is one more Wasm guest, not a second engine beside Wasm. The engine is written in Odin (src/engine): a
front-end that compiles source to QuickJS-ng 0.17 bytecode, the interpreter (jsrt, with a hot loop of its own and a loop
JIT that compiles hot loops and hot functions to Wasm at run time: numbers, properties guarded by shapes, small callees
inlined, compiled functions called directly, natives and getters called, objects and strings made;
engine/jit.md), and the numeric, Unicode, regexp and date libraries. jscapi gives it
the QuickJS C API, which the glue (native/js/odin, all Odin) is written against; the web platform's objects are
hand-written members plus bindings generated from the Web IDL of webref (src/tools/bindgen, package jsgen, into
native/js/odin/bindings_gen.odin). src\tools\buildjs compiles it all to one wasm32 module (Odin, linked by Odin's own wasm-ld, no C library:
no C of ours is compiled) and pre-initializes it at build time (src/wasm/preinit.odin: interfaces,
prototypes and preludes already built in the memory image); the image is embedded in the exe (src/js/image). Each page
and each JavaScript worker gets its own instance (a realm), whose
imports are the same api/*.widl functions Wasm pages use, plus js*.widl for what only the engine needs. So the
limits of every guest apply: Wasmtime's sandbox, the memory cap, epoch interruption (wasm.max_call_ms), on top of
the engine's own (javascript.max_heap_bytes, javascript.slow_script_ms). A trap loses the realm (the status bar says
JS stopped), never the browser. Wrappers hold their DOM nodes; wrappers of one DOM tree are grouped at each
collection so that a detached tree nothing holds is freed (src/page/js_realm.odin). Script loading
(src/page/js_load*.odin) follows HTML's processing model. Details: native/js/README.md; coverage:
docs/js/coverage.md.
Image and font decoders
src/images decodes on its own threads, never on the interface thread. Every format but HEIF is decoded by the crate
native\media, part of Wren's one Rust static library (see "The Rust library" below), the same on every system; HEIF
goes through the platform (WIC, when the Store codec is installed), and images.decode.system_codecs hands PNG, JPEG,
GIF, BMP, ICO and TIFF to WIC as before (src/images/parity_test_windows.odin compares both on every fixture):
- PNG / APNG, JPEG, GIF, BMP, ICO, TIFF: image 0.25.10 (png, zune-jpeg, gif, tiff;
native/media/src/raster.rs): EXIF orientation, ICC profiles, GIF and APNG animation composed by the decoder, the ICO entry that fits the size shown; PNG and JPEG encoding for the canvas.
- SVG: resvg 0.48.1 (usvg + tiny-skia; text with the system fonts through fontdb). Rendered at the size it is
shown at (device pixels), in the secure static mode of SVG images (no external resource, script or animation);
hostile documents are bounded by
images.svg.*(nodes, depth, entity and<use>expansion, rendering cost). Inline<svg>elements of HTML documents are drawn by the same renderer (src/page/inline_svg.odin); the HTML of their<foreignObject>elements is laid out and painted by Wren's own layout, in the SVG's coordinate space: the viewBox mapping, nested<svg>viewports and the transforms around it, its<clipPath>s (user space or bounding box units) and the viewports of the nested<svg>s it is in (unlessoverflow: visible), intersected, as a polygon clip, the filters of it and its ancestors: filter functions, and<filter>s whose primitives chain blurs,feColorMatrixsaturate / hueRotate and a linearfeComponentTransferasblur(),saturate(),hue-rotate(),brightness()(src/layout/foreign_object.odin). The picture is drawn in parts split at the foreign objects, so that shapes drawn after one cover it (Svg_Parts). An inline<svg>clips to its viewport (svg:not(:root) { overflow: hidden }). Not yet:<filter>s of other primitives (offsets, floods, merges, colour matrices). - WebP: image-webp 0.2.4 (pure safe Rust: lossy, lossless, alpha, animation).
- AVIF: zenavif-parse 0.6.2 (container: alpha, grids, sequences, colr / irot / imir / clap) + rav1d 1.1.0 (the Rust port of dav1d, with dav1d's assembly: NASM 2.16.03 is fetched and checked by SHA-256 by the script).
- Colour: qcms 0.3.0 (Firefox's): ICC profiles of every format, AVIF's CICP code points and EXIF's Adobe RGB to
sRGB (
images.color_management). - WOFF2 web fonts: brotli-decompressor 6.1.0, then the glyf / loca / hmtx transforms reversed
(
native/media/src/woff2.rs, used bysrc/webfont).
Every crate is pinned (see below). wrentool gen media-fixtures (ImageMagick, FFmpeg) made the AVIF / WebP test files of examples\site\media;
src\tools\mediabench times decodes.
The Rust library (native/)
A program links one Rust static library at most: each carries its own copy of Rust's standard library, and two built
by the same compiler define the same symbols. So all of Wren's Rust code is one Cargo workspace, native/, with one
Cargo.lock, one target directory (build\native\target) and one output, build\native\wren_native.lib
(wrentool build native, run by build.bat; nothing is done when build\native\STAMP matches the sources):
native/media(cratewren_media, a library): the image decoders and WOFF2 above (src/images,src/webfont).native/net(cratewren_net): the network stack (src/net/rust.odin, see "Network" below).- The toolkit's crates, path dependencies that stay in the toolkit:
ext/gui_toolkit/native/text(gt_text, the native text backend; featuretext, left out of aWREN_TEXT=dwritebuild) andext/gui_toolkit/native/a11y(gt_a11y, AccessKit; featurea11y). The toolkit is built with-define:FONT_NATIVE_LINK=false -define:A11Y_NATIVE_LINK=false: it links nothing of its own. native/wren_native, the assembly crate (astaticlibwith no code): it picks the libraries, and their C functions (wm_*,wn_*,gt_*) are exported from it.
Two Cargo profiles, with the same code generation for each crate (opt-level = 3, one codegen unit, panic =
"unwind": every entry point catches panics):
release(build.bat releaseandprofile: what is shipped, and measured as shipped): fat LTO, the whole graph optimized as one module. A change in any crate runs that step again over all of them (a minute or more).quick(build.bat,debug,check, the CI): no LTO, each crate's objects go into the archive as they are. A change in one crate rebuilds that crate and the archive: touching the accessibility adapter recompiles neither the decoders nor the text backend (7 s against 65 s). Measured againstreleaseon 2026-10-27: decoding as fast (WebP, AVIF, SVG, WOFF2 within 3 %), shaping 2 to 5 % slower, the exe 0.6 MB larger.
Both stay compiled in build\native\target; switching between them copies the library. The CI caches the compiled
crates by profile and Cargo.lock (.github/actions/setup): a run whose Cargo.lock did not change compiles only
the workspace's and the toolkit's crates.
Why not a DLL per library (cdylib): measured, the adapter's DLL is 0.6 MB and the text backend's 2.4 MB (each with
its own standard library), loading one costs 2 to 3 ms (40 ms the first time a new file is scanned), and each DLL is
one more file to sign, ship and keep in step with the exe; wren.exe needs no file beside it, and stays so.
Every crate is pinned by exact version in its Cargo.toml and by SHA-256 in native\Cargo.lock (--locked);
wrentool gen rust-crates writes the list about:licenses shows (native\rust-crates.txt). Wasmtime's library carries
a standard library of another compiler: its mangled symbols differ, and the one unmangled symbol both define
(rust_eh_personality, unused on MSVC) is renamed in ours with llvm-objcopy.
Network
Package net is the transport: queues per priority, per-host and global limits, throttling (DevTools), generational
handles, scopes, back-pressure, the Headers / Data / Done / Failed events, WebSockets' state machine. Under it,
one of two stacks (network.backend, overridden by the WREN_NET_BACKEND environment variable):
rust(the default):native/net, the same on every platform. tokio threads of its own; hyper-util's pooled client over our connector (the system resolver, TCP attempts staggered by 250 ms, the system proxy: environment, Windows registry, macOS configuration, with a CONNECT tunnel for https and WebSockets), HTTP/1.1 and HTTP/2 by ALPN; rustls (ring) with rustls-platform-verifier: the system's trust store and policy, plusnetwork.tls.extra_ca_file; gzip, deflate and Brotli decoded as the body streams; WebSockets on tungstenite over an HTTP/1.1 upgrade. HTTP/3 (quinn + h3) for an origin that announced it (Alt-Svc): the request that opens the QUIC connection waits for itnetwork.http3.race_msat most, then goes over TCP; the others use QUIC only once it is up; an origin where QUIC failed is left to TCP fornetwork.http3.broken_host_ms. Results wait in the library's queue; one notification per burst postsnet's drain to the loop, which takes them one by one (wn_net_poll) and copies what it keeps: no allocation of ours on a library thread, no memory of the library freed by us. Proxy auto-configuration scripts (PAC) are not run: where the system proxy names one, the client uses WinHTTP.winhttp: WinHTTP's asynchronous mode (Windows only). Never HTTP/3: WinHTTP does not fall back to TCP where QUIC does not get through.
Errors keep WinHTTP's numbers (12002 timeout, 12007 name, 12029 connect, 12030 lost, 12152 invalid answer, 12175 /
12037 / 12038 / 12045 certificates with WinHTTP's secure flags) whichever stack ran the request. src/tools/e2e/web_net.odin
runs the transport on both; WREN_NET_BACKEND=rust|winhttp makes a web test run by hand use one.
Security model
- Origins.
scheme://host:port, or an opaque origin equal only to itself (file:,data:, redirect-tainted requests). Sites use the Public Suffix List (github.iois a suffix;localhostand IPs have no registrable domain). - Requests carry an Environment:
Request_Desc.initiatoris the document asking (nil = the address bar). Modes:navigate,same-origin,no-cors(opaque response: status 0, no headers, no body for scripts) andcors. - CORS: Origin header, ACAO / ACAC checks, header exposure filtering, preflights with the spec's rules
(safelisted methods and headers,
*never coveringAuthorization, wildcard disabled with credentials), preflight cache with max-age. Cross-origin Wasm modules need CORS; modules must beapplication/wasm. - Cookies: stored and sent only by
fetch(WinHTTP's are off).Secureneeds a secure context (localhost counts),__Secure-/__Host-prefixes,SameSite(default Lax,NoneneedsSecure), HttpOnly invisible to scripts, "leave Secure cookies alone", Domain checked against the PSL. Third-party cookies are blocked by default.document.cookieexists only for tuple origins. - HSTS, referrer policy (header,
<meta>, per request), mixed content (blocked; images auto-upgraded), bad ports, Fetch Metadata headers (Sec-Fetch-*), CSP (headers and<meta>, report-only,upgrade-insecure-requests,block-all-mixed-content;script-srcgoverns Wasm modules,worker-srcworkers). - Local files: a
file:document may read files in its own directory tree and nothing else; web pages never read local files; only the address bar may open any file.
Web Authentication (passkeys)
src/webauthn is the client of Web Authentication Level 3 without the page: the options in their standard JSON form
(what a server sends, what navigator.credentials.create() / get() serialize a dictionary to), the RP ID (a
registrable domain suffix of the document's domain, Public Suffix List of src/url), the exact bytes of
clientDataJSON, the ceremony timeout, extensions, toJSON(), and CBOR / COSE for getPublicKey() (DER
SubjectPublicKeyInfo). The authenticators are the operating system's, behind an interface (Platform): on Windows
webauthn.dll (Windows Hello, security keys, a phone over hybrid, the passkey managers Windows knows), loaded on first
use and used up to the API version the system has; its dialog belongs to the browser's window. A software authenticator
(virtual.odin, ES256 through BCrypt, keys in memory) replaces it for automated tests only. src/page/bindings_webauthn.odin
adds the page's conditions (secure context, a recent click or key press, the tab shown in the focused window, one
ceremony at a time) and runs the blocking platform call on a thread of its own; the window stays responsive and draws
nothing while the user answers, the address bar shows a chip (click: cancel). Aborts, the lifetime timer and the page
closing cancel the system's operation; if the system does not give it back within security.webauthn.cancel_grace_ms,
the page is answered anyway and the exit does not wait for it. Nothing about credentials is logged.
Rendering pipeline
DOM mutations and style changes are journaled (src/dom/journal.odin, src/style/journal.odin). A frame runs, as
needed: the style pass (full, in parallel on large documents, or a partial restyle from invalidation sets), the layout
pass (refresh, incremental or full; layout.incremental.verify checks an incremental pass against a full one), the
display list, then the compositor (src/browser/compositor.odin), which records the display list into retained
pictures of the toolkit's draw layer and replays them every frame: scrolling and transform animations only change
matrices. The painter (src/browser/render.odin) maps the display list onto draw's transform, clip and layer stacks
(filters, blend modes, backdrops, masks). Frames are paced on the display's refresh by the toolkit's cadence model (platform/pacing.odin, its docs/pacing.md: one decision per frame), which src/browser/pacing.odin executes.