Articles
WebAssembly Interview Questions: Linear Memory, Host Imports, and JavaScript Boundaries
Practice WebAssembly interviews with a JS image-processing case: linear memory, stale views after growth, host imports, data transfer, and fair benchmarks.
A browser image filter becomes faster after its inner loop moves to WebAssembly, yet the page takes longer to finish each frame. On larger images, a JavaScript Uint8Array that worked before a resize seems empty. The module also calls an imported JavaScript logger once per pixel. An interviewer could ask three different questions here: Did the algorithm improve, did the memory contract break, and did crossing the host boundary erase the gain?
This guide uses an original practice fixture, not a candidate report about a particular employer. The WebAssembly specifications and JavaScript API define the underlying behavior; the image filter, performance symptoms, and proposed fixes are scenario assumptions and engineering inferences. The strongest interview answer traces bytes, ownership, calls, and measurements through one complete request. For a prerequisite, practice Explain arrays and memory layout.
The interview fixture: one image through two runtimes
Assume JavaScript receives an RGBA image and allocates space for it in a module's ordinary unshared, fixed-length WebAssembly.Memory. The module exports process(ptr, length), a function that changes pixel bytes in place. It may import env.report, a host function implemented in JavaScript. The fixture starts with one 64-KiB memory page and grows when a larger input needs more space. The application holds a typed-array view of the module's memory so it can copy bytes in and read them back.
Clarify the interface before proposing code. Does the module import a JavaScript-created memory or export its own? Who allocates the input region, and does process grow memory internally? Is the output in place or at a different pointer? Can report run per pixel, per row, or once per image? Does the displayed latency include fetching, compilation, instantiation, copying, encoding, drawing, and the Wasm call? A plausible answer changes with these details.
The urgent questions are: what does a linear-memory pointer represent; when do JS views become stale; what can imported host functions do; how are values and strings passed; why can a fast Wasm loop produce a slow feature; and what tests prove the fix? Work through those in that order. A pointer by itself is not an owned JavaScript object, a memory view is not permanently valid, and a compiled loop is not the whole product experience.
Question 1: What is linear memory, and what does ptr mean?
The core specification defines linear memory as a contiguous sequence of bytes accessible by Wasm load and store instructions. A module may have memory provided by its host or export a memory for the host to inspect. In this fixture, ptr is a byte offset into the particular WebAssembly.Memory shared by the JS wrapper and module; length is the number of image bytes, not a JavaScript object reference or pixel count. Four-channel RGBA pixels imply width × height × 4 bytes if there is no row padding. That byte-count equation is a fixture assumption, not a universal image format rule.
Before constructing a JS view, check that the chosen offset and length describe a region actually allocated for this image and fit within the current memory. A useful guard is ptr >= 0, length >= 0, and ptr + length <= memory.buffer.byteLength, with integer and overflow checks appropriate to the interface. That bounds check does not prove the region belongs to this request: two valid regions may overlap. Define an allocation protocol, ownership, and a release policy separately. Wasm bounds checks protect against out-of-bounds linear-memory access; they do not make an application-level pointer/length pair semantically correct.
JavaScript can expose bytes through new Uint8Array(memory.buffer, ptr, length). Multi-byte fields need an agreed encoding and byte order; MDN's memory reference recommends DataView for explicit little-endian reads and writes. A four-byte width header might use view.setUint32(headerOffset, width, true) and the corresponding getUint32(..., true). If an interviewer gives you a corrupted image, inspect offset, length unit, alignment assumptions, and output location before blaming the Wasm arithmetic.
Question 2: Why did the JavaScript view fail after memory growth?
The Memory.grow reference says pages are 64 KiB, and a JavaScript memory.grow(delta) call returns the previous page count. For the ordinary unshared fixed-length memory in this fixture, growth detaches the old ArrayBuffer, including when delta is zero. A typed array built from that old buffer can no longer serve as the live window into memory. Re-read memory.buffer and make a fresh view after any operation that may grow memory. The exact behavior differs for shared or resizable memory forms; do not apply this fixture's detached-view diagnosis to every memory configuration without checking it.
Here is a trace of the assumed wrapper, rather than paste-ready code for an arbitrary compiled module:
| Moment | Memory and view | What the candidate should conclude |
|---|---|---|
| Start | One page; JS creates inputView from memory.buffer | inputView addresses the current buffer only |
| Copy image | JS writes bytes at ptr; module reads ptr, length | Bytes, allocation, and length unit must agree |
| Larger image | Wrapper or module grows memory | The previous fixed unshared buffer is detached |
| Buggy readback | JS reuses inputView | It may look empty or fail; it is not a valid post-growth view |
| Safe readback | JS creates a view from the current memory.buffer | Validate the new range and inspect the intended output region |
The repair is a lifecycle rule: after a function that can grow memory returns, acquire memory.buffer again, then rebuild the view using the current output offset and length. If growth can happen inside an exported function, rebuilding only after explicit JavaScript memory.grow() calls is insufficient. Conversely, replacing a JS view does not allocate Wasm-side space or prevent an overlapping write. Keep the buffer-view fix distinct from the module's allocation protocol.
A focused test starts with an input that fits one page, then crosses the page boundary. It records memory.buffer before and after growth, verifies the expected output bytes through a newly created view, and checks that code never uses the old view afterward. Include a growth-limit case. If growth can fail, the wrapper must report that failure rather than continuing with an assumed larger range. Repeat with the actual runtime's memory configuration; shared memory has different view semantics.
Question 3: What do host imports and exports really connect?
The WebAssembly JavaScript interface describes instantiating a module with an import object whose named values satisfy the module's declared imports. An exported function is then available through instance.exports. In the fixture, JavaScript may provide { env: { report } }, but only if the compiled module declares env.report with a compatible signature. The names and function types form part of the module's interface; an arbitrary JS property is not automatically callable from Wasm.
An imported report function runs in the host. It could append to an array, call console.log, or perform other behavior allowed by the surrounding JS environment. Therefore, “Wasm is sandboxed” is not an answer to whether this feature has side effects. The module's direct Wasm access is constrained by its runtime, but an import is an explicit capability supplied by the host. Review what the host grants, validate indices or lengths passed to imports, and decide how host exceptions should surface. Do not imply that importing a function makes its side effects harmless.
For this image filter, a report(pixelIndex) call on every pixel is a poor interface if reporting only needs a final count. Moving a calculation into Wasm while calling back to JavaScript for every iteration repeatedly crosses the boundary. A better interface may return a scalar count, write a compact result into memory, or call report once per image. That is an engineering design inference, not a normative rule that all imports are slow: imports can be appropriate for I/O, clocks, logging at coarse granularity, and capabilities the module cannot provide itself. Measure the actual crossing cost in the target runtime.
Question 4: How do numbers, strings, and images cross the boundary?
Basic exported function calls exchange Wasm value types, not arbitrary JavaScript arrays or strings by magic. MDN's i64 reference documents the current JavaScript BigInt mapping for i64; a JS Number is not an interchangeable 64-bit integer argument. For an image, the wrapper copies bytes into linear memory or arranges another agreed memory-sharing scheme, then passes offset and byte length. If the module returns a new output pointer, the wrapper must read that region before releasing or reusing it.
Strings require an explicit ABI: choose an encoding such as UTF-8, encode with TextEncoder, put bytes into allocated linear memory, and pass an offset and byte count. Decode returned bytes with TextDecoder using the agreed length. A pointer alone does not say where a string ends, what encoding it uses, or who frees it. Multi-byte structures similarly need documented field layout and endianness. These choices matter in debugging because a “Wasm bug” may be a mismatched wrapper convention.
The choice to copy is also a performance question. A large batch may justify transfers; a tiny transformation may spend most of its time preparing data. Neither zero-copy nor a speedup is automatic. Check the browser API, memory ownership, output format, and wrapper design.
Question 5: How would you prove or reject the claimed speedup?
The core calculation is end-to-end latency and throughput, with correctness held equal. A fair comparison uses the same image set, output specification, device/browser, concurrency, and warmup policy for an optimized JavaScript baseline and the Wasm implementation. Measure both a warm repeated operation and cold startup if users experience module fetch, compilation, or instantiation. Do not subtract copying from Wasm time while leaving input preparation inside JS time.
| Variant in this original fixture | What it isolates | Expected interpretation if it wins or loses |
|---|---|---|
| Optimized JS loop | Competitive baseline with no Wasm boundary | If already fast, migration needs a measurable product gain |
| Wasm kernel, one input and output per image | Computation plus coarse boundary cost | A large batch may amortize transfer and call overhead |
Wasm kernel with report per pixel | Repeated imported-call cost | A regression suggests boundary frequency matters; verify in a profiler |
| Wasm kernel with one summary report | Same calculation, fewer host calls | If faster, prefer a coarse interface where semantics permit |
| Warm and cold runs separately | Startup versus steady state | A warm win may still lose for one-off page use |
Those are hypotheses to test, not universal performance predictions. Instrument the JS wrapper around byte copy-in, exported call, callbacks, copy-out, and rendering. Count host calls and bytes transferred. Verify identical pixels or a specified numerical tolerance before comparing timings. Report distributions such as median and tail latency rather than only one best run; repeat enough samples to see noise and memory-growth outliers. Profiling can reveal an unexpected browser API conversion or allocation that a simple timer hides.
If the kernel improves but the whole frame slows, first remove per-pixel imports or batch calls, then check copies and allocation. If growth only occurs on rare large frames, test those frames separately rather than averaging them away. If the module is used once, include its loading and instantiation path; if reused across many frames, amortize startup transparently. Choose the version that improves the user-facing target without sacrificing correctness, maintainability, or a sensible fallback.
Question 6: What failures and boundaries belong in the final answer?
A strong candidate treats invalid offsets, detached views, failed growth, missing imports, host exceptions, and Wasm traps as different failures. Log which stage failed, the image dimensions, current memory size, and an error category; avoid dumping image content or sensitive input. Bounds checks at the JS edge and a clear Wasm-side allocation contract are complementary. Make the host imports minimal and explicit. If the filter crashes, a fallback may render the original image or use a tested JS path, depending on the product requirement.
The final explanation should be modest about isolation. The Wasm runtime constrains its own memory operations, but the host decides which imports are available and what those imports can do. An imported callback can introduce I/O, reentrancy, exceptions, and performance cost. The interface is a security and operational design decision as well as a syntax detail.
For a five-minute answer, draw the path browser bytes → current memory view → ptr/length → exported process → optional host import → fresh output view → display. Mark the possible growth point and count every boundary crossing. Then name the two tests that would change your conclusion: a page-boundary growth test for correctness and a whole-frame benchmark against optimized JS for performance.
Sources and further reading
- W3C WebAssembly Core Specification — linear memory and execution semantics.
- W3C WebAssembly JavaScript Interface — imports, exports, memory objects, and JS integration.
- MDN: WebAssembly.Memory — host-visible memory buffers and views.
- MDN: WebAssembly.Memory.grow() — page growth and buffer/view behavior.
- MDN: Using the WebAssembly JavaScript API — an end-to-end JS integration example.
- MDN: i64 value type — BigInt at the JavaScript boundary.