Measuring Wasm Compile Time in DevTools

This guide locates the time in a WebAssembly load: how much is network, how much is compilation, how much is instantiation, and how much is the first call — which is not a loading cost at all despite usually being blamed as one.

Getting this right is the prerequisite for every other optimisation in this area. The four phases have different fixes, and the common failure is spending a week shrinking a binary to solve a problem that lived entirely in the first invocation.

Which panel shows which phase Download timing lives in the Network panel. Compilation appears in the Performance panel as a scripting block. Instantiation and the first call are best measured with your own user timing marks, because they are short and interleaved with application code. Network panel download duration, size on the wire content-type and content-encoding phase 1, and whether streaming is even allowed Performance panel a scripting block named for compilation on the main thread or a worker track phase 2, and whether it is a long task your own marks performance.mark / measure around instantiate and the first call phases 3 and 4, which nothing else separates User timing marks also appear in the Performance panel's timings track, so all four phases end up on one screenshot.

Prerequisites

  • [ ] A page that loads the module on demand, so you can profile the load in isolation
  • [ ] Network throttling set to something representative, not your office connection
  • [ ] Cache disabled for a genuine cold start, and enabled for the repeat-visit measurement
  • [ ] A quiet machine — background work distorts short measurements badly

Step-by-step procedure

1. Instrument the phases explicitly

performance.mark("wasm:fetch:start");
const response = await fetch(WASM_URL);
performance.mark("wasm:fetch:end");

performance.mark("wasm:compile:start");
const module = await WebAssembly.compileStreaming(Promise.resolve(response));
performance.mark("wasm:compile:end");

performance.mark("wasm:instantiate:start");
const instance = await WebAssembly.instantiate(module, imports);
performance.mark("wasm:instantiate:end");

performance.mark("wasm:first-call:start");
instance.exports.warmup();
performance.mark("wasm:first-call:end");

for (const phase of ["fetch", "compile", "instantiate", "first-call"]) {
  performance.measure(`wasm:${phase}`, `wasm:${phase}:start`, `wasm:${phase}:end`);
}
console.table(performance.getEntriesByType("measure")
  .filter((m) => m.name.startsWith("wasm:"))
  .map((m) => ({ phase: m.name, ms: m.duration.toFixed(1) })));

Note that separating fetch from compile requires the non-streaming shape, which is slightly slower than instantiateStreaming. Use this instrumented version for diagnosis and the streaming version in production.

2. Read the Network panel for the download

Filter to Wasm and check three columns: size (compressed), time, and the Content-Type. A binary served without application/wasm never streams, and that shows up as a slower total even though the network row looks fine.

3. Record a Performance profile

Start recording, load the module, stop. Look for a scripting block on the main thread whose name refers to Wasm compilation, and check whether it is flagged as a long task.

4. Compare cold and warm

Run the same measurement twice: once with the cache disabled, once with it populated. The difference tells you how much of your load is network and how much is CPU — which decides whether caching or size reduction is the lever.

5. Separate the first call

The first invocation includes tier-up: the engine is still running baseline-compiled code. Measure a second call immediately after to see the difference, and do not attribute either to loading.

Expected output

┌─────────┬─────────────────────┬─────────┐
│ (index) │        phase        │   ms    │
├─────────┼─────────────────────┼─────────┤
│    0    │ 'wasm:fetch'        │ '142.6' │
│    1    │ 'wasm:compile'      │ '188.3' │
│    2    │ 'wasm:instantiate'  │  '3.9'  │
│    3    │ 'wasm:first-call'   │ '46.2'  │
└─────────┴─────────────────────┴─────────┘

// second call, immediately after
first-call  46.2 ms
second-call  8.1 ms      ← the difference is tier-up, not loading

That table answers the question directly. Compilation dominates, so the levers are size reduction and a compiled-module cache. If instead first-call had been four hundred milliseconds, the answer would be that this is a compute problem and no loading work would have helped.

Three shapes, three different answers A network-dominated profile calls for compression and caching. A compile-dominated profile calls for a smaller binary and a compiled-module cache. A profile dominated by the first call is not a loading problem at all and calls for work on the algorithm. network-bound → compress, cache compile-bound → shrink, cache the Module compute-bound → not a loading problem Blue is download, purple is compile, pink is the first call. Identify your shape before choosing a fix.

Gotchas

No compile block appears in the profile. Either the module is small enough to compile in a sub-millisecond slice, or compilation happened on a worker track you have collapsed. Check both before concluding it is free.

Timings vary wildly between runs. Short measurements are noisy and performance.now() is deliberately coarse in most browsers. Repeat, take a median, and treat single runs as indicative only.

The instrumented version is slower than production. Expected — separating fetch from compile forfeits streaming. Instrument to diagnose, then switch back.

Warm and cold measurements look identical. The cache was not actually cleared, or the resource has no caching headers so it never populated. Verify with the Network panel’s size column.

First call and compile blur together. They are adjacent and both scripting time. The user timing marks are the only reliable separation.

Measure the second call too The first invocation runs baseline-compiled code and includes the engine's tier-up work, so it is not representative. Timing a second identical call immediately afterwards separates one-off startup cost from the steady-state execution cost. first call baseline code + tier-up + the work 46 ms second call 8 ms — the steady-state cost The 38 ms difference is one-off. Attributing it to loading leads to optimising the binary; attributing it to the algorithm leads to optimising code that is already fast. It belongs in a third category: engine warm-up, which you influence only by calling the module earlier.

Performance note

Instrumentation is cheap — performance.mark is a few microseconds — so leaving the marks in a production build behind a flag is reasonable and occasionally valuable, because the phase split on real devices is often different from the split on a developer machine. Real user monitoring of these four measures answers the question “is our Wasm load a problem for anyone” far better than any local profile can.

If you keep only one number, keep the compile duration. It is the phase most affected by decisions you control — binary size and caching — and the one that varies most between devices, because it is pure CPU work on a machine whose speed you cannot predict.

Taking the measurement to real users

A local profile tells you about one machine. What decides whether the module is a problem is the distribution across your actual audience, and the same marks that produced the local table can be reported from production at negligible cost:

const nav = performance.getEntriesByType("navigation")[0];
const marks = Object.fromEntries(
  performance.getEntriesByType("measure")
    .filter((m) => m.name.startsWith("wasm:"))
    .map((m) => [m.name.slice(5), Math.round(m.duration)])
);

metrics.event("wasm.startup", {
  ...marks,
  ttfb: Math.round(nav.responseStart),
  connection: navigator.connection?.effectiveType,
  memory: navigator.deviceMemory,
});

The two extra fields matter more than they look. Effective connection type separates the download problem from the compile problem across your audience — a module that is fine on broadband and painful on a slow connection needs compression work, not size work. Device memory is a rough proxy for CPU class, and compile time correlates with it strongly, so a high p75 concentrated on low-memory devices points at the binary size rather than the network.

Sample rather than reporting every session. A few per cent gives a stable distribution and keeps the volume manageable, and the shape of the distribution — not the mean — is what you want: the median tells you the typical experience, and the 75th and 95th percentiles tell you how bad it gets for the users most likely to leave.

Reading the result as a decision

Once you have the distribution, the decision rules are short. A p75 compile time above roughly 200 ms means the binary is large enough that either a module cache or a size reduction will pay off, and the cache is usually the cheaper of the two. A p75 download time much larger than compile means compression or caching headers are missing or ineffective. A first-call time dominating both means the module is not a startup problem at all and the effort belongs in the algorithm.

Writing those thresholds down before looking at the data is worth doing, because it is otherwise very easy to look at a number, find it unsatisfying, and start optimising whichever phase is easiest to change rather than the one that is actually costing time.

Frequently Asked Questions

Does instantiateStreaming make the phases unmeasurable? It merges fetch and compile, which is exactly the point. Measure them separately when diagnosing, using the instrumented form, and use the merged total as the production metric.

Why is compilation slower in Firefox than Chrome, or vice versa? Different tiering strategies: some engines do more work up front for faster steady-state code. Compare each browser against itself over time rather than against another browser.

Should I measure on a phone? Yes, if any users are on one. Compilation is CPU-bound and mid-range phones are several times slower than a laptop, so a compile that is invisible in development can be the dominant cost in the field.

Why does the profile show compilation happening twice? Some engines compile with a fast baseline tier first and re-compile hot functions with the optimising tier afterwards, and both can appear as scripting work. The first is startup cost; the second overlaps execution and is not something to optimise away. Distinguishing them by when they occur relative to your first call is usually enough.

Should I measure with the cache enabled or disabled? Both, separately. The disabled measurement is the first-visit experience and the one that shapes a first impression; the enabled measurement is what returning users get. Optimising the wrong one is a common way to spend effort without changing what anybody experiences.

Do the marks survive into production builds? They do unless you strip them, and leaving them costs microseconds. Gating the reporting rather than the marking is usually the better arrangement: the marks are always present, and whether they are sent anywhere is a configuration decision you can change without a deploy.

Can I compare numbers between two releases? Yes, provided the measurement is unchanged and the sampling is comparable. Recording the release identifier alongside the timings makes that comparison a query rather than an archaeology exercise, and it is the fastest way to attribute a startup regression to the change that caused it.

← Back to Module Caching & Startup Performance