Fix issues
diff --git a/ui/config/jest.unittest.config.js b/ui/config/jest.unittest.config.js
index 7c81890..9557c7f 100644
--- a/ui/config/jest.unittest.config.js
+++ b/ui/config/jest.unittest.config.js
@@ -46,6 +46,9 @@
     // Lezer .grammar imports: stub out (Vite compiles them in production via
     // @lezer/generator, but no test exercises the parser).
     '\\.grammar$': __dirname + '/grammar_mock.js',
+    // ui/src/wasm/* shims point at emcc-emitted glue via a Vite alias. Jest
+    // doesn't replicate the alias and no test runs wasm — stub them out.
+    '/wasm/[^/]+$': __dirname + '/wasm_mock.js',
     // Some TS files use ESM-style explicit ".js" extensions on relative
     // imports of sibling TS sources (e.g. `import './language.js'`). Jest's
     // resolver doesn't map .js -> .ts, so strip the extension here.
diff --git a/ui/config/wasm_mock.js b/ui/config/wasm_mock.js
new file mode 100644
index 0000000..355cb0d
--- /dev/null
+++ b/ui/config/wasm_mock.js
@@ -0,0 +1,22 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Stub for `ui/src/wasm/*` imports under jest. Vite aliases these to the
+// emcc-emitted glue in `ui/src/gen/*` at build time; jest doesn't run that
+// alias, and no unit test exercises the wasm runtime. A factory that throws
+// makes accidental invocations obvious.
+const factory = () => {
+  throw new Error('wasm runtime is not available in jest');
+};
+module.exports = {default: factory, __esModule: true};
diff --git a/ui/src/base/proto_utils_wasm.ts b/ui/src/base/proto_utils_wasm.ts
index e9f83c6..439b6b7 100644
--- a/ui/src/base/proto_utils_wasm.ts
+++ b/ui/src/base/proto_utils_wasm.ts
@@ -14,6 +14,7 @@
 
 import protos from '../protos';
 import {assetSrc} from './assets';
+import {defer} from './deferred';
 import {errResult, okResult, type Result} from './result';
 import {utf8Decode, utf8Encode} from './string_utils';
 import WasmModuleGen from '../wasm/proto_utils';
@@ -128,13 +129,16 @@
     // emscripten uses sync-loading, which works only in Workers.
     const resp = await fetch(assetSrc('proto_utils.wasm'));
     const wasmBinary = await resp.arrayBuffer();
-    const instance = await WasmModuleGen({
+    const deferredRuntimeInitialized = defer<void>();
+    const instance = WasmModuleGen({
       noInitialRun: true,
       locateFile: (s: string) => s,
       print: (s: string) => console.log(s),
       printErr: (s: string) => console.error(s),
+      onRuntimeInitialized: () => deferredRuntimeInitialized.resolve(),
       wasmBinary,
     });
+    await deferredRuntimeInitialized;
     const bufAddr = instance.ccall('proto_utils_buf', 'number', [], []) >>> 0;
     const bufSize =
       instance.ccall('proto_utils_buf_size', 'number', [], []) >>> 0;
diff --git a/ui/src/engine/index.ts b/ui/src/engine/index.ts
index 8370dac..802e71a 100644
--- a/ui/src/engine/index.ts
+++ b/ui/src/engine/index.ts
@@ -30,5 +30,5 @@
 // Receives the boostrap message from the frontend with the MessagePort.
 selfWorker.onmessage = (msg: MessageEvent) => {
   const port = msg.data as MessagePort;
-  void wasmBridge.initialize(port);
+  wasmBridge.initialize(port);
 };
diff --git a/ui/src/engine/wasm_bridge.ts b/ui/src/engine/wasm_bridge.ts
index ce26e21..f0c6467 100644
--- a/ui/src/engine/wasm_bridge.ts
+++ b/ui/src/engine/wasm_bridge.ts
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import {assertExists, assertTrue} from '../base/assert';
+import {defer} from '../base/deferred';
 import {PerfettoWasmModule} from '../wasm/perfetto_wasm_module';
 
 // The 64-bit variant of TraceProcessor wasm is always built in all build
@@ -46,11 +47,7 @@
 //               - [JS] postMessage() (this file)
 export class WasmBridge {
   private aborted: boolean;
-  // Resolves once the wasm runtime is fully initialized AND the rpc init
-  // ccall has completed. All callers that need to touch `connection` must
-  // await this first.
-  private readonly ready: Promise<PerfettoWasmModule>;
-  private connection!: PerfettoWasmModule;
+  private connection: PerfettoWasmModule;
   private reqBufferAddr = 0;
   private lastStderr: string[] = [];
   private messagePort?: MessagePort;
@@ -58,39 +55,33 @@
 
   constructor() {
     this.aborted = false;
+    const deferredRuntimeInitialized = defer<void>();
     this.useMemory64 = hasMemory64Support();
     const initModule = this.useMemory64 ? TraceProcessor64 : TraceProcessor32;
-    this.ready = initModule({
+    this.connection = initModule({
       locateFile: (s: string) => s,
       print: (line: string) => console.log(line),
       printErr: (line: string) => this.appendAndLogErr(line),
-    }).then((module) => {
-      this.connection = module;
-      const fn = module.addFunction(this.onReply.bind(this), 'vpi');
+      onRuntimeInitialized: () => deferredRuntimeInitialized.resolve(),
+    });
+
+    deferredRuntimeInitialized.then(() => {
+      const fn = this.connection.addFunction(this.onReply.bind(this), 'vpi');
       this.reqBufferAddr = this.wasmPtrCast(
-        // ccall: 'pointer' is what emscripten accepts at runtime, but DT's
-        // typings only model 'number'/'string'/'array'/'boolean'. Use
-        // 'number' here — emscripten treats them as equivalent for ccall
-        // arg/return marshalling.
-        module.ccall(
+        this.connection.ccall(
           'trace_processor_rpc_init',
-          /* return=*/ 'number',
-          /* args=*/ ['number', 'number'],
+          /* return=*/ 'pointer' as 'number',
+          /* args=*/ ['pointer', 'number'] as ('number')[],
           [fn, REQ_BUF_SIZE],
         ),
       );
-      return module;
     });
   }
 
-  async initialize(port: MessagePort) {
+  initialize(port: MessagePort) {
     // Ensure that initialize() is called only once.
     assertTrue(this.messagePort === undefined);
     this.messagePort = port;
-    // Wait until the wasm runtime + rpc buffer are ready before wiring the
-    // port handler — otherwise queued messages would land in onMessage()
-    // before `this.connection` exists.
-    await this.ready;
     // Note: setting .onmessage implicitly calls port.start() and dispatches the
     // queued messages. addEventListener('message') doesn't.
     this.messagePort.onmessage = this.onMessage.bind(this);
diff --git a/ui/src/frontend/trace_converter.ts b/ui/src/frontend/trace_converter.ts
index 5f5c9be..6fddc0e 100644
--- a/ui/src/frontend/trace_converter.ts
+++ b/ui/src/frontend/trace_converter.ts
@@ -86,7 +86,7 @@
     }
   }
 
-  const worker = new Worker(assetSrc('traceconv_bundle.js'));
+  const worker = new Worker(assetSrc('traceconv_bundle.js'), {type: 'module'});
   worker.onmessage = handleOnMessage;
   worker.postMessage(msg);
   return promise;
diff --git a/ui/src/traceconv/index.ts b/ui/src/traceconv/index.ts
index 56dc8d4..dcf8092 100644
--- a/ui/src/traceconv/index.ts
+++ b/ui/src/traceconv/index.ts
@@ -14,6 +14,7 @@
 
 import {addErrorHandler, type ErrorDetails, reportError} from '../base/logging';
 import {assertExists} from '../base/assert';
+import {defer} from '../base/deferred';
 import type {time} from '../base/time';
 import traceconv from '../wasm/traceconv';
 
@@ -79,12 +80,15 @@
 }
 
 async function runTraceconv(trace: Blob, args: string[]) {
-  const module = await traceconv({
+  const deferredRuntimeInitialized = defer<void>();
+  const module = traceconv({
     noInitialRun: true,
     locateFile: (s: string) => s,
     print: updateStatus,
     printErr: updateStatus,
+    onRuntimeInitialized: () => deferredRuntimeInitialized.resolve(),
   });
+  await deferredRuntimeInitialized;
   module.FS.mkdir('/fs');
   module.FS.mount(
     assertExists(module.FS.filesystems.WORKERFS),
diff --git a/ui/src/wasm/proto_utils.d.ts b/ui/src/wasm/proto_utils.d.ts
index 58d862d..ad66f66 100644
--- a/ui/src/wasm/proto_utils.d.ts
+++ b/ui/src/wasm/proto_utils.d.ts
@@ -17,5 +17,8 @@
 // Type shim for the emcc-emitted 'proto_utils' wasm glue. The runtime path is
 // resolved by Vite (alias 'src/wasm/*' -> 'src/gen/*') to the actual .js
 // file in the gen dir; tsc resolves this .d.ts and never reads the .js.
-declare const factory: EmscriptenModuleFactory<PerfettoWasmModule>;
+// Our emcc build uses WASM_ASYNC_COMPILATION=0, so the factory returns the
+// Module synchronously — not Promise<Module> as @types/emscripten's
+// EmscriptenModuleFactory would suggest.
+declare const factory: (opts?: Partial<PerfettoWasmModule>) => PerfettoWasmModule;
 export default factory;
diff --git a/ui/src/wasm/trace_processor.d.ts b/ui/src/wasm/trace_processor.d.ts
index 7c997c3..ffd5e2b 100644
--- a/ui/src/wasm/trace_processor.d.ts
+++ b/ui/src/wasm/trace_processor.d.ts
@@ -17,5 +17,8 @@
 // Type shim for the emcc-emitted 'trace_processor' wasm glue. The runtime path is
 // resolved by Vite (alias 'src/wasm/*' -> 'src/gen/*') to the actual .js
 // file in the gen dir; tsc resolves this .d.ts and never reads the .js.
-declare const factory: EmscriptenModuleFactory<PerfettoWasmModule>;
+// Our emcc build uses WASM_ASYNC_COMPILATION=0, so the factory returns the
+// Module synchronously — not Promise<Module> as @types/emscripten's
+// EmscriptenModuleFactory would suggest.
+declare const factory: (opts?: Partial<PerfettoWasmModule>) => PerfettoWasmModule;
 export default factory;
diff --git a/ui/src/wasm/trace_processor_memory64.d.ts b/ui/src/wasm/trace_processor_memory64.d.ts
index 24779ac..7193fae 100644
--- a/ui/src/wasm/trace_processor_memory64.d.ts
+++ b/ui/src/wasm/trace_processor_memory64.d.ts
@@ -17,5 +17,8 @@
 // Type shim for the emcc-emitted 'trace_processor_memory64' wasm glue. The runtime path is
 // resolved by Vite (alias 'src/wasm/*' -> 'src/gen/*') to the actual .js
 // file in the gen dir; tsc resolves this .d.ts and never reads the .js.
-declare const factory: EmscriptenModuleFactory<PerfettoWasmModule>;
+// Our emcc build uses WASM_ASYNC_COMPILATION=0, so the factory returns the
+// Module synchronously — not Promise<Module> as @types/emscripten's
+// EmscriptenModuleFactory would suggest.
+declare const factory: (opts?: Partial<PerfettoWasmModule>) => PerfettoWasmModule;
 export default factory;
diff --git a/ui/src/wasm/traceconv.d.ts b/ui/src/wasm/traceconv.d.ts
index fb47fc2..5d51039 100644
--- a/ui/src/wasm/traceconv.d.ts
+++ b/ui/src/wasm/traceconv.d.ts
@@ -17,5 +17,8 @@
 // Type shim for the emcc-emitted 'traceconv' wasm glue. The runtime path is
 // resolved by Vite (alias 'src/wasm/*' -> 'src/gen/*') to the actual .js
 // file in the gen dir; tsc resolves this .d.ts and never reads the .js.
-declare const factory: EmscriptenModuleFactory<PerfettoWasmModule>;
+// Our emcc build uses WASM_ASYNC_COMPILATION=0, so the factory returns the
+// Module synchronously — not Promise<Module> as @types/emscripten's
+// EmscriptenModuleFactory would suggest.
+declare const factory: (opts?: Partial<PerfettoWasmModule>) => PerfettoWasmModule;
 export default factory;
diff --git a/ui/vite.config.mjs b/ui/vite.config.mjs
index 864fcd5..e7a7d8f 100644
--- a/ui/vite.config.mjs
+++ b/ui/vite.config.mjs
@@ -206,15 +206,19 @@
 // Per-bundle config: input file, output dir (relative to ui/out), and output
 // filename. Most bundles follow the standard convention; service_worker and
 // chrome_extension differ.
+// Bundles loaded into a Worker that needs `import.meta.url` (emscripten wasm
+// glue) MUST be ES modules — IIFE would rewrite import.meta.url into a
+// `document.currentScript` polyfill that crashes inside a worker.
+// Main-thread bundles loaded by a classic <script> tag stay as IIFE.
 const BUNDLE_CONFIGS = {
-  frontend:            {dir: 'dist_version',                 entry: 'index.ts'},
-  engine:              {dir: 'dist_version',                 entry: 'index.ts'},
-  traceconv:           {dir: 'dist_version',                 entry: 'index.ts'},
-  bigtrace:            {dir: 'dist_version/bigtrace',        entry: 'index.ts'},
-  open_perfetto_trace: {dir: 'dist/open_perfetto_trace',     entry: 'index.ts'},
-  chrome_extension:    {dir: 'chrome_extension',             entry: 'index.ts'},
+  frontend:            {dir: 'dist_version',                 entry: 'index.ts', format: 'iife'},
+  engine:              {dir: 'dist_version',                 entry: 'index.ts', format: 'es'},
+  traceconv:           {dir: 'dist_version',                 entry: 'index.ts', format: 'es'},
+  bigtrace:            {dir: 'dist_version/bigtrace',        entry: 'index.ts', format: 'iife'},
+  open_perfetto_trace: {dir: 'dist/open_perfetto_trace',     entry: 'index.ts', format: 'iife'},
+  chrome_extension:    {dir: 'chrome_extension',             entry: 'index.ts', format: 'iife'},
   service_worker:      {dir: 'dist',                         entry: 'service_worker.ts',
-                        fileName: 'service_worker.js'},
+                        fileName: 'service_worker.js',       format: 'iife'},
 };
 const bundleCfg = BUNDLE_CONFIGS[BUNDLE];
 if (!bundleCfg) {
@@ -298,7 +302,7 @@
       input: {[BUNDLE]: inputPath},
       treeshake: NO_TREESHAKE ? false : undefined,
       output: {
-        format: 'iife',
+        format: bundleCfg.format,
         name: BUNDLE,
         entryFileNames,
         // With cssCodeSplit:false Vite emits the CSS as "style.css" by