Space-efficient Embedding of WebAssembly in JavaScript

Recently, I came across a blog post about converting parts of a JavaScript library into WebAssembly. The part that interested me the most was a section about efficiently embedding the WebAssembly binary into the JavaScript code such that the library could be distributed as a single file, instead of the usual method of providing the WebAssembly binary as a separate file. This is accomplished by Base64-encoding the WebAssembly binary as a string and including the resulting string in the JavaScript file. Unfortunately, this significantly inflates the total file size, since the Base64-encoded string does not compress nearly as well as the original binary. To mitigate this issue, the blog post author had the clever idea of gzip-compressing the binary prior to Base64-encoding it and using the zlib.js JavaScript library to decompress the binary client-side, after undoing the Base64-encoding. While this significantly reduced the size of the Base64-encoded WebAssembly binary, it required ~6.5 kB for the decompression code, after gzip compression.1

While I liked the idea of compressing the WebAssembly binary prior to Base64-encoding it, I thought there must be a way of decompressing it with a smaller decompression code. The simplest change would be to use a raw Deflate-compressed payload instead of one encapsulated with gzip, as the zlib.js library also provides a decompression function for this, which is only ~2.5 kB after gzip-compression, saving ~4 kB. However, this is still excessive, since it shouldn’t be necessary to provide a Deflate decompression function as web browsers include such functionality. Although such decompression functionality isn’t exposed directly to JavaScript, PNG images can be decoded from JavaScript, and PNG images use Deflate compression. Thus, I decided to encode the WebAssembly binary as a grayscale PNG image, Base64-encode the PNG as a data URI, and include the resulting string in the JavaScript file.

To encode the binary as a PNG image, the dimensions of the image must first be decided on. For this, I decided to set the image width to the smallest power of two that allowed the image to have a landscape aspect ratio, although this decision was somewhat arbitrary. Each pixel in the grayscale image corresponds to one byte in the WebAssembly binary, starting in the top-left corner of the image and wrapping line-by-line. Any remaining pixels in the last row of the image were set to zero, but this presented a problem, since zero-padding a WebAssembly binary is not allowed. Thus, the first four pixels of the image are used to store the size of the WebAssembly binary as an unsigned 32-bit little-endian integer, which can then be used by the decoder to truncate the image data to the correct length. The resulting PNG image can then be optimized using tools such as OxiPNG to reduce its file size further, after which the PNG image is Base64-encoded as a data URI.

To decode the Base64-encoded PNG image into WebAssembly from JavaScript, the Image() constructor is used to create an <image> element from the Base64-encoded string. Then, the <image> is drawn to a <canvas> element, and the getImageData() method is used to extract the image data as an array. The array is then filtered to keep only every fourth pixel, to convert the RGBA data to grayscale.2 Next, the first four bytes containing the WebAssembly binary length are decoded, removed from the array, and used to truncate the array to just the WebAssembly binary data. Finally, these data are used to instantiate the WebAssembly code. The decoding routine is <0.3 kB after gzip compression.

I have made available a demo that includes an encoding procedure written in Python, the JavaScript decoding procedure, and a live example using MDN’s simple WebAssembly example. While this demonstrates the technique, it doesn’t provide a meaningful example of the bandwidth savings. Thus, I also applied the technique to the ammo.js WebAssembly demo. For this example, the original WebAssembly binary is ~651 kB uncompressed, ~252 kB when compressed with gzip -9, and ~218 kB when compressed with brotli -9. As a Base64-encoded string, it is ~880 kB, which is reduced to ~358 kB when compressed with gzip -9 or ~316 kB when compressed with brotli -9. When converted to a PNG image using the technique described in this blog post and optimized using OxiPNG, the resulting image is ~242 kB. When converted to a Base64-encoded data URI, it is ~322 kB, which is reduced to ~244 kB when compressed with gzip -9 or ~242 kB when compressed with brotli -9. While not quite as small as the Brotli-compressed binary, the technique described in this blog post does better than the gzip-compressed binary and does much better than naively include a Base64-encoded binary in JavaScript.

  1. The author mentioned 12 kB of decompression code, but that was without gzip-compression of the JavaScript code.  

  2. This step wouldn’t be necessary for a RGBA PNG, but these don’t seem to compress WebAssembly binaries quite as well as grayscale PNGs.  

This entry was posted in and tagged , , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *