crossbind
GitHub
GUIDE · BUILD · PERFORMANCE AND SIZE

Performance and size

Every build links only what your imports reach and compiles with production flags. Most apps should change none of the defaults; this page shows what ships, the few flags worth tuning, and why native code has to earn its place with a measurement.

What ends up in the binary

Libraries link as static archives, and a static link takes only the parts of an archive your bindings reach. Release builds compile with -O3; debug builds skip optimization, and the bundler dev servers serve debug builds, so measure size and speed on a production build.

The bindings decide what is reachable. Your own headers bind every function. A header from a dependency - a port or a ConanCenter package - binds all its classes and enums but only the free functions you import from it by name, so a function you never name, and the library code only it reaches, stays out:

src/main.js
import { GDALAllRegister, GDALOpenEx, GDALClose } from '@crossbind/port-gdal/gdal.h';

import * as, import(), require and a bare import 'x.h' take the header whole and bind every function. Measured for the 2.0.0-beta.63 release on this site's library demos, which call a port straight from JavaScript, binding only the imported functions shrank the wasm by 47% for GEOS, 40% for OpenSSL, 11% for zlib and 4% for GDAL. C++ has the binding rules.

Android ships libraries whole
crossbind builds Android libraries as shared libraries (.so), which keep everything they export: on Android, importing fewer functions trims the bindings, not the libraries.

Two more things add to what ships. Data files you declare travel with the module (Data files). export.wholeArchive: true links every archive whole instead of only the parts it needs - meant for libraries whose members register themselves from static initializers.

Speed: measure, do not assume

Native code is not automatically faster than JavaScript. Every call crosses a boundary that converts its arguments and results, and for a small function the crossing can cost more than the work. What a call costs depends on what crosses - a number, a string, a const char*, a callback into JavaScript - and Node.js has measured numbers for wasm against a Node-API addon.

  • Benchmark the real workload against the JavaScript you would otherwise ship, with representative inputs, on a production build.
  • Keep loops on the native side. One call that runs a whole pipeline crosses the boundary once; a call per element crosses it every time.
  • Count the worker hop. In a browser useWorker is on by default, so every call is also a message to the module's worker and back (Runtime API).
  • Profile with the browser's tools: the Performance tab for time spent in JavaScript and wasm, the Memory tab for the heap, and Lighthouse for the download, which counts toward the page's load metrics.

The defaults

Every wasm build gets these. If the build runs and the app works, leave them as they are.

SettingDefaultNotes
Optimization-O3 in release builds, none in debugan -O flag of your own comes later and replaces it
SIMD-msimd128on every wasm build
Exceptions-fwasm-exceptionsa C++ throw reaches JavaScript as an Error
HeapALLOW_MEMORY_GROWTH=1starts at Emscripten's initial size and grows on demand
Stack64 KB, or 1 MB when the link includes Rustfixed size, no guard page
Threads (mt builds)-pthread, a pool of Math.min(navigator.hardwareConcurrency || 1, 2) workers, PTHREAD_POOL_SIZE_STRICT=2at most two pthread workers; asking for more aborts
Function tableRESERVED_FUNCTION_POINTERS=200raise it on Cannot enlarge function table
FilesystemWASMFS and FORCE_FILESYSTEM=1 in browser and Node.js buildsm.FS, OPFS and data files rely on them
No evalDYNAMIC_EXECUTION=0the module runs under a strict Content Security Policy
Array buffersGROWABLE_ARRAYBUFFERS=0Firefox's and WebKit's TextDecoder reject views over resizable buffers, which would break every string crossing the boundary

Safe overrides

Emscripten flags go under binary.emccFlags in a targetSpecs entry and apply to wasm builds. Filter the entry by platform, arch, runtime, buildType or runtimeEnv so a flag reaches only the builds that need it.

crossbind.config.js
export default {
paths: { config: import.meta.url },
targetSpecs: [
{
platform: 'wasm',
specs: { binary: { emccFlags: ['-sINITIAL_MEMORY=64MB'] } },
},
],
};
FlagReach for it when
-sINITIAL_MEMORY=64MBstartup allocates a lot - a model, a large dataset - and you see pauses while the heap grows
-sSTACK_SIZE=1MBthe C++ uses large local arrays or deep recursion. An overflow is silent in a release build: it overwrites static data, and later, unrelated calls fail with unreachable or memory access out of bounds. Add -sSTACK_OVERFLOW_CHECK=2 to find the function.
-sPTHREAD_POOL_SIZE=8 with -sPTHREAD_POOL_SIZE_STRICT=1an mt build measurably scales past two threads. Strict mode 1 warns and grows the pool instead of aborting.
-sRESERVED_FUNCTION_POINTERS=1024a run fails with Cannot enlarge function table

A wasm32 heap cannot pass 4 GB. For more, build the wasm64 arch: crossbind build -a wasm64, which a plain build skips, or target: { arch: 'wasm64' } with a bundler plugin. Every flag crossbind sets, with what each one is for, is listed in performance.md.

What to leave alone

  • -Os or -Oz for a smaller file: an -O flag in emccFlags comes after the default -O3 and replaces it, trading speed for size. Measure both on your own binary before keeping it.
  • Turning off ALLOW_MEMORY_GROWTH for predictable allocation: the first allocation past the initial heap then fails with out of memory instead of growing.
  • A pool of 32 pthreads for parallelism: each pthread is a Web Worker with its own startup time, JavaScript runtime and stack - only the heap is shared - so raise the pool only as far as a measurement shows the work scales.
  • Flags that fight the defaults: the loader, m.FS, OPFS and data files depend on MODULARIZE, WASMFS and FORCE_FILESYSTEM, strict-CSP pages on DYNAMIC_EXECUTION=0, and C++ errors in JavaScript on -fwasm-exceptions - std::vector, std::string and std::map throw even when your own code does not.
Type to search every guide page and section.
↑↓ navigate↵ openesc close