Sep 22, 2026 · Virtastic
Porting FreeCAD to wasm64: what broke after the compile succeeded
Eleven runtime traps from three months of carrying FreeCAD, Qt 6, OCCT, Coin3D and CPython to 64-bit WebAssembly with JSPI. Each one compiled cleanly, passed a scripted test, and did nothing when a person clicked. Symptom, mechanism, fix, with the upstream line.
freecad-web 1.0 is FreeCAD 1.1.3 compiled to wasm64-emscripten
and running in a browser tab: Qt 6.11, OCCT 7.8.1, Coin3D 4.0.3, VTK 9.3.1, CPython 3.13.3,
PySide6, numpy, matplotlib, IfcOpenShell, with Gmsh and CalculiX as separate wasm modules.
Getting it to compile and link took a few weeks. Getting it to behave like desktop FreeCAD took the other two and a half months, and almost none of that went into compile errors. It went into things that compiled cleanly, linked cleanly, passed a scripted test, and did nothing when a person clicked. This post is about those.
One rule came out of it and now sits at the top of the project’s working agreements: a
scripted test through the Python bridge is a weaker claim than a real mouse click, and a return
value can be the failure. Most of what follows is that rule being learned the expensive way.
Every number here is from
BUILD-WEH.md or
MANUAL-QA.md in the repository, and the commit hashes are real.
The shape of the thing
185 MB of wasm, 293 MB of preloaded file system, 1.4 MB of JS glue. Wasm exceptions in the
new try_table encoding, JSPI for suspending into JavaScript, pthreads with a pool of 16, a
heap that starts at 1 GiB and can grow to 16 GiB, which is where V8 caps 64-bit memory. Coin3D
is a fixed-function OpenGL renderer, so it runs on emscripten’s legacy GL emulation on top of
WebGL 2. Nothing is dynamically loaded: every Python extension is in the inittab and on the
link line, and if it is missing from either it builds fine and fails to import.
The server is nginx serving static files with brotli. The browser fetches about 115 MB on the first visit and nothing on the second. That second part took longer than it should have.
1. Only promising exports may suspend
JSPI lets a wasm export be marked promising: calls into it can suspend on a JavaScript promise and resume later. FreeCAD needs this constantly, because a modal dialog is a nested event loop and a nested event loop in a browser is a suspension.
Every dialog opened when driven from Python through our promising export. Not one opened from a real mouse click. The gate said “0 page errors” and the harness reported success, because the harness was driving Python.
The mechanism, once we stopped theorising and read qstdweb.cpp:743-751: Qt’s DOM event
listener enters wasm through an embind call that is not a promising export. QDialog::exec
called from that path tried to suspend and threw SuspendError: trying to suspend without WebAssembly.promising, which Qt swallowed. The identical C++ from a promising path was fine.
The fix is one promising export, fcweb_dispatch_event, and a drop-in replacement for the
listener class that routes real DOM events through it. Drag and drop in the tree had the same
disease (QDrag::exec) and the same cure.
2. JSPI switches the wasm stack, not the C stack
When a promising call suspends, JSPI saves the wasm execution stack. It knows nothing about the shadow stack that C code keeps in linear memory for locals whose address is taken. The shadow stack pointer is a global, and a second promising activation that starts while the first is suspended happily runs on top of the first activation’s frames.
The symptom: a Gmsh mesh from the FEM workbench wrote its result correctly and then CPython
died with Fatal Python error: Executing a cache. The trace showed Qt’s main loop had resumed
15 times inside one gmsh call, each time over the suspended Python call’s frames.
The fix (466d90c) gives every promising export a private malloc’d stack, from a pool, for
the duration of the call.
3. The keyboard never entered Qt
Typing into a text field worked perfectly. Delete, Ctrl+Z, Escape and every shortcut did nothing, silently.
Qt for WebAssembly focuses a hidden DOM input element only when a text widget has focus. With
anything else focused, document.activeElement is the body, and keydown never reaches Qt at
all. This was found with an application-wide Qt event filter that logged every key event: it
logged the letters and nothing else.
The fix forwards trusted key events to Qt’s canvas, with isTrusted as the loop guard and
skipped when a text field has focus, so that typing abc123 still yields exactly abc123.
The test is per focus target, with real key events, because a bridge-driven test cannot see
this.
4. The compositor recorded the frame and never presented it
The threaded Qt build showed a black viewport. Single-threaded, identical C++, showed the model. Ten theories were written down and disproved before the measurement that mattered: instrument the GL glue and count draws and blits that reach the default framebuffer. On the threaded build, zero. Ever. Qt recorded the compose into an offscreen target and the present pass never happened.
A bisect on FreeCAD 1.0.0 with nothing but -feature-thread toggled turned 9,485 distinct
colours in the viewport into one. The fix (691e7bd) has the page perform the final present
pass itself each animation frame. Threading stays on.
Closely related: Qt’s RHI draws on Coin’s GL context, and emscripten’s emulation state is global across contexts. Every external GL state reset needs a symmetric reassert, or Coin’s lazy element cache decides nothing changed and skips it. “Missing” geometry was unoccluded geometry.
5. The 2 MB ceiling in immediate mode
An 18 MB STL threw from getTempVertexBuffer. Coin had emitted one glBegin of 153,600
vertices, 4.3 MB of them, against emscripten’s GL.MAX_TEMP_BUFFER_SIZE of 2 MB. Before the
throw, typed-array stores past the end were being silently discarded, so smaller meshes were
being truncated without anyone noticing.
The buffer now grows on demand (8a2f343). The obvious alternative, switching Coin to VBOs,
was measured and rejected: a 626-solid STEP file never became responsive within 600 seconds
with VBOs on, because VBO-on routed Mesh nodes onto emulation code that had never run.
6. Display lists had never worked
glGenLists returns 0 in emscripten’s legacy GL emulation. Coin treats 0 as “no display
list” and re-traverses the node every frame. It had been doing that since the first link. A
profile of a BIM drag showed 63% of the frame in scene traversal.
The fix (c741277) records every GL import between glNewList and glEndList in JS,
snapshots the client-side arrays, and replays on glCallList. 1,769 replayed operations take
9.6 ms; 7 pixels of 2.6 million differ from the direct path. BIMExample went from 39.6 to
50.3 fps.
7. Qt timers starve, and Chrome clamps the rest to 4 ms
Two timer problems, one after the other.
First: once any promising activation calls processEvents, it overwrites the single resume
slot the main loop was parked on, and onTimer is a no-op under asyncify. A 1 second
QTimer ticked five times and died. The page now pumps Qt once per native timer wake.
Second, after that: Chrome clamps nested setTimeout(0) to 4 ms, and Qt parks on one per
processEvents. The main thread was 61% idle in a profile while frames took 16.7 ms.
Zero-delay Qt timers moved to a MessageChannel (273a785), and BIMExample went to 6.9 ms
per frame. Desktop is 13.3 on the same machine.
8. Python shipped with no bytecode
1,486 .py files under Mod, 0 .pyc. Every boot compiled Draft from source: 1.6 seconds
for import Draft against 2 ms warm. The packager stamps files with the unpack time, so
timestamp-validated bytecode never validates. Unchecked-hash bytecode compiled between install
and link (13cc5f3) took the import to 0.36 s and a 42 MB assembly open from 71 s to 28 s.
9. wasm64 was mostly fine, except where it was not
Moving from wasm32 to wasm64 grew the binary 12.7% and removed the memory ceiling. Three things bit:
- Pointers are BigInts. The post-link GL patches used
>> 2to turn a byte pointer into a heap index; BigInt will not shift by a Number, so it becomes/ 4. Only 4 of the 48 patch anchors carry a heap index, but each was a silent wrong answer until found. glShaderSourcereads itsGLint *lengtharray at pointer width (d3aa482). Correct by accident on wasm32, every Qt shader arrived truncated on wasm64 with “Missing main()”. The entire widget layer vanished as soon as a 3D view existed, while Coin, which is fixed-function and has no shaders, kept drawing. Every gate that photographed the viewport passed. The gate now scans for Qt shader compile failures.- IfcOpenShell’s schema registry took the renderer to 8 GB and Chrome killed it
(
5eadc88). Not the heap and not TurboFan: V8 lazily compiling three generated functions of 1.0 to 1.4 MB each, in a 116 MB code section with pointers twice as wide.-Oz -fno-inlineon the generated schema files only.
10. The engine was never cached
Every visit downloaded the engine again: 113 MB and two to three minutes, immutable and a
one-year max-age notwithstanding. Chrome’s disk cache will not retain entries of that size.
Cache Storage will, and reads a 152 MB entry back in 111 ms. Cold boot went from 171 s to
23 s and a return visit from 115 s to 8 s with 0 bytes fetched.
A related trap: the JS, wasm and data files are one artifact and are served immutable-for-a-year with md5-stamped names. A returning visitor with a cached JS and a fresh wasm gets a dead boot, and only returning visitors do, which is why one gate deliberately reuses a browser profile.
11. Things that only a person can see
Two classes of defect that no harness catches, and that now live on the manual QA pass:
- A modal sitting mid-canvas with a console reporting zero errors. The stale-cache boot failure was exactly this. When a harness says everything is fine and nothing works, take a screenshot.
- Chromium’s native file picker, which no script can answer, and a native drag that Chrome refuses to begin from synthesised input. The drop pipeline is verified by simulation; only a hand proves the gesture starts.
What we changed upstream, and what we did not
Every delta lives as a patch in patches/: 8,506 lines across 18 files, 7,162 of them
against FreeCAD itself. The rule was to adapt upstream code rather than replace it. A guard on
sys.platform == "emscripten" around a subprocess call keeps the feature and removes the
crash; a reimplementation of the feature is a parity bug waiting to diverge. The blocking
dialog module exists so that a Python-triggered dialog still returns the user’s real choice
rather than a default.
Things the desktop does that this build knowingly does not, all on the front page: Firefox and Safari (no JSPI yet), multi-threaded CalculiX, and an OpenSCAD binary for CSG evaluation.
The numbers, for the record
On an idle machine with an RTX 4080, dev tree of 2026-09-13: EngineBlock opens in 0.3 s and drags at 37.8 fps; BIMExample opens in 7.1 s and drags at 58.2 fps; the 42 MB a2plus assembly opens in 20.2 s and drags at 25.5 fps. Against desktop FreeCAD 1.1.3 on the same machine, the web build is faster on Draft-heavy files (ArchDetail by 5 to 7 times), 2 to 5 times behind on BIMExample, and within 2x on open times everywhere. The per-frame floor is about 20 ms.
Everything above, with the measurement that found it and the upstream line where the
mechanism lives, is in
BUILD-WEH.md. The
harnesses that produced the numbers are in scratchpad/. If you are porting a large Qt or
Python application to wasm and hit something that looks like one of these, that file is
probably the fastest hour you will spend.
The release post has what the result looks like from the user’s side. Try it or read the source.
Have a desktop app that belongs in the browser?
We recompile legacy desktop software to WebAssembly, then maintain and host it. See it proven on your own app.