← All posts

Why Your Rust/WASM Bundle Is Larger Than It Needs to Be: From Cargo to the Browser

We wrote a small Rust library for our own image editor running on a web page. It is not part of the browser engine: JavaScript passes image pixels to the library, which applies three operations, grayscale, invert, and brightness, and returns the modified buffer.

The library has no interface, network client, or complex data model. It contains only three filters over an RGBA buffer, compiled to WebAssembly.

An RGBA buffer stores each pixel as four values: red, green, blue, and alpha. One pixel therefore occupies four bytes.

The first WebAssembly build of this core occupies 2629023 bytes:

$ cargo build --target wasm32-unknown-unknown
$ stat -c %s target/wasm32-unknown-unknown/debug/pixeledit_core.wasm
2629023

Two and a half megabytes for three small functions seems to confirm Rust/WASM's reputation: even simple code turns into a heavy browser bundle.

But a file's size does not tell us what fills it. It may contain executable instructions, debugging data, temporary export descriptions, function names, an allocator, and error-handling code. Those parts exist for different reasons and are removed by different tools.

WebAssembly, or WASM, is a binary instruction format for a virtual machine. The browser loads a .wasm module, validates it, and executes it independently of the language from which it was compiled.

We will inspect the original file section by section, then pass the same build through a release profile, wasm-bindgen, and wasm-opt. After each change, we will look inside the binary again. This order matters more than any ready-made checklist: a setting is useful only when it removes bytes that actually exist in this particular program. The complete source and build scripts are collected in the appendix.

Three Filters Cross the Same ABI

The grayscale filter calculates luminance with integer Rec.601 coefficients and leaves the alpha channel unchanged:

use wasm_bindgen::prelude::wasm_bindgen;

const LUMA_R: u16 = 77;
const LUMA_G: u16 = 150;
const LUMA_B: u16 = 29;

#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
    for px in pixels.chunks_exact_mut(4) {
        let r = u16::from(px[0]);
        let g = u16::from(px[1]);
        let b = u16::from(px[2]);
        let luma = ((r * LUMA_R + g * LUMA_G + b * LUMA_B) >> 8) as u8;
        px[0] = luma;
        px[1] = luma;
        px[2] = luma;
    }
}

chunks_exact_mut(4) yields only complete RGBA groups. If one, two, or three bytes remain at the end of the buffer, the iterator ignores them. The px[0], px[1], and px[2] accesses therefore remain inside a four-byte chunk and create no reachable out-of-bounds panic path.

The #[wasm_bindgen] attribute exposes the function to JavaScript. The neighboring invert and brightness functions accept the same argument type, so all three exports cross the same boundary between JavaScript memory and WebAssembly linear memory.

wasm-bindgen connects Rust and JavaScript. Its Rust crate describes exported types and functions, while its command-line tool generates JavaScript glue and transforms the original WASM module into a browser-ready form.

The dependency version is pinned exactly:

[dependencies]
wasm-bindgen = "=0.2.126"

The command-line tool reads metadata produced by the crate, so their versions must match down to the patch release. This does not affect the filter algorithm, but it determines whether the browser pipeline can process the module at all.

We can now determine which parts of those two and a half megabytes belong to the filters and which appeared around them.

Debug Sections Occupy Almost the Entire First File

stat reports only the total size. twiggy breaks a WASM module down by section and function:

twiggy is a WebAssembly size analyzer. Its Shallow Bytes column counts bytes owned by the section or function itself, excluding the cost of objects to which it refers.

$ twiggy top -n 6 target/wasm32-unknown-unknown/debug/pixeledit_core.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼───────────────────────────────
       1386095 ┊    52.72% ┊ custom section '.debug_str'
        672805 ┊    25.59% ┊ custom section '.debug_info'
        328267 ┊    12.49% ┊ custom section '.debug_line'
        122264 ┊     4.65% ┊ custom section '.debug_ranges'
         28829 ┊     1.10% ┊ "function names" subsection
         15092 ┊     0.57% ┊ custom section '.debug_abbrev'
         75671 ┊     2.88% ┊ ... and 459 more.
       2629023 ┊   100.00% ┊ Σ [465 Total Rows]

The five .debug_* sections occupy 2524523 bytes, about 96% of the entire file. They store strings, types, address ranges, and mappings from instructions back to source lines.

DWARF is a format for debugging information. A debugger uses it to connect binary instructions with functions, variables, and source lines; the browser does not need it to execute the program.

The original 2.6 MB therefore says almost nothing about the program's executable size. It is primarily a debugging container around a comparatively small module.

A release build removes this layer and enables optimization:

$ cargo build --release --target wasm32-unknown-unknown
$ stat -c %s target/wasm32-unknown-unknown/release/pixeledit_core.wasm
37832

The file drops from 2629023 to 37832 bytes, a reduction of 98.6%. The first conclusion is now precise: comparing a browser bundle with a debug build is meaningless because almost all of the difference is DWARF, not more compact executable instructions.

A Release Binary Is Still Not the Browser Module

Once DWARF is gone, different components rise to the top:

$ twiggy top -n 8 target/wasm32-unknown-unknown/release/pixeledit_core.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼─────────────────────────────────────────────
          7561 ┊    19.99% ┊ custom section '__wasm_bindgen_unstable'
          6968 ┊    18.42% ┊ "function names" subsection
          5102 ┊    13.49% ┊ <dlmalloc::dlmalloc::Dlmalloc<...>>::malloc
           952 ┊     2.52% ┊ __rustc::__rdl_realloc
           878 ┊     2.32% ┊ core::str::count::do_count_chars
           873 ┊     2.31% ┊ <dlmalloc::dlmalloc::Dlmalloc<...>>::free
           797 ┊     2.11% ┊ data segment ".rodata"
           673 ┊     1.78% ┊ <core::fmt::Formatter>::pad
         14028 ┊    37.08% ┊ ... and 210 more.
         37832 ┊   100.00% ┊ Σ [218 Total Rows]

Only crate hashes inside long function names have been shortened in the twiggy output; sizes and percentages are unchanged.

The first two rows occupy 14529 bytes, or 38% of the file, but neither is filter code.

The __wasm_bindgen_unstable section stores type and export descriptions for the command-line tool. unstable means that this internal format requires matching crate and CLI versions, not that the module itself is unreliable. The section is removed after JavaScript generation, while the adjacent function-name section remains useful only to analyzers and debuggers.

The largest real function here is dlmalloc::malloc. The grayscale formula does not allocate memory directly; the allocator appears because of the way &mut [u8] crosses the JavaScript/WASM boundary. We will return to that cost after the final optimization.

For now, the important point is that even cargo build --release produces an intermediate artifact. Optimizing only this file is insufficient because some of its bytes are intended for the next tool, not for the browser.

Profile Settings Do Not Guarantee a Smaller File

A typical checklist for compact Rust/WASM looks convincing:

opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

Each setting controls a different part of the build:

  • opt-level = "z" asks LLVM to prefer smaller code even when doing so disables some speed-oriented transformations;
  • lto = true permits link-time optimization across crate and code-generation-unit boundaries;
  • codegen-units = 1 compiles the crate as one unit so the optimizer can see more code at once, at the cost of a slower build;
  • panic = "abort" terminates execution on panic without unwinding the stack;
  • strip = true removes symbol names and debugging information from the resulting binary.

Enabling everything at once would produce a smaller result without revealing which setting helped. We will instead test settings independently, then add them cumulatively and measure the same unprocessed Cargo binary after every step:

step                                                bytes
release (opt-level 3)                               37832
opt-level=s alone                                   40082
opt-level=z alone                                   42959
z + lto                                             42957
z + lto + codegen-units=1                           42743
z + lto + cu=1 + panic=abort                        42743
z + lto + cu=1 + panic=abort + strip                31809

The first results contradict the names of the settings. opt-level = "s" increases the file by 2250 bytes, while "z" adds 5127 compared with the standard opt-level = 3 release build.

s asks LLVM to reduce size while retaining some speed-oriented optimizations. z is more aggressive and disables loop vectorization to avoid keeping vector and scalar versions together. These modes change the optimizer's priorities, but neither guarantees the smallest file for every program.

How the vector or scalar path is selected

When vectorization is enabled, LLVM writes both paths into the WASM module ahead of time:

if at least 16 bytes remain -> loop with v128 SIMD instructions
after that                  -> scalar loop for the remainder

The path is selected at runtime from the buffer length, not from the processor model. The browser's JIT translates v128 into suitable native instructions, such as SSE on x86 or NEON on ARM. If the environment does not support WASM SIMD, a module containing those instructions will not load; it does not automatically switch to a scalar fallback.

Consequently, z does not have to be smaller than s, and both may lose to opt-level = 3 for a particular code graph. That is exactly what happened in this small crate.

The next two settings barely move the result. lto = true removes only 2 bytes, and codegen-units = 1 removes another 214. A small core with one direct dependency has little code to combine across compilation units.

panic = "abort" changes nothing: the file is 42743 bytes before and after it. On wasm32-unknown-unknown, a panic already ends in a trap instead of unwinding the stack, so the original binary contained no unwinding tables.

A trap is an emergency stop in WASM code: the runtime ends the call immediately and reports an error to JavaScript. The Rust stack is not unwound, so destructors do not run. Unwinding tables describe how to walk backward through the stack and perform cleanup; a trap does not need them.

This does not mean that all panic-related code disappeared. panic = "abort" changes how execution ends, but it does not remove reachable error-formatting functions. A reference analysis later will show why they remain.

Finally, strip = true removes 10934 bytes. It drops names and other debugging data, making it responsible for almost the entire reduction produced by the profile checklist.

A control build makes this even clearer:

profile slim-z (Cargo.toml)                         31809
profile slim-s (Cargo.toml)                         31351
strip alone (opt-level 3)                           30565
SELF-CHECK OK: cumulative ladder == slim-z profile

A standard release build with only strip occupies 30565 bytes and is smaller than both complete configurations. Copying a universal set of settings without intermediate measurements makes the result worse here.

Yet even this comparison is not being made against the file the browser loads.

wasm-bindgen Removes Its Own Temporary Layer

wasm-bindgen has two parts. During compilation, the Rust crate writes a temporary export description into the WASM module: which functions JavaScript may call and how their arguments must be converted. The CLI, a separate command-line program, runs after Cargo. It reads that description, generates JavaScript glue, and writes a new WASM module.

A regular cargo build does not run the CLI itself. Cargo creates the initial WASM module, but it does not know whether JavaScript is needed for a browser, a module bundler, or Node.js, nor where that output should be placed. This step is invoked separately or delegated to a tool such as wasm-pack or Trunk.

wasm-pack build and trunk build perform the wasm-bindgen stage automatically, so the CLI does not need to be run manually. wasm-pack assembles a package containing WASM and JavaScript, while Trunk creates the bindings after Cargo and places them in the browser bundle.

$ wasm-bindgen target/wasm32-unknown-unknown/slim-s/pixeledit_core.wasm \
      --target web --out-dir pkg
$ stat -c %s pkg/pixeledit_core_bg.wasm
15638

The size falls from 31351 to 15638 bytes, a reduction of 50.1%. Once the glue has been generated, the browser no longer needs the __wasm_bindgen_unstable section or the helper functions that described the exports. The CLI removes this consumed temporary layer and can then remove code reachable only from it.

No Cargo setting can produce this reduction. The temporary data is required between rustc and wasm-bindgen, so it cannot be removed earlier. Each stage of the pipeline owns a different part of the result.

The module then passes through wasm-opt:

wasm-opt is an optimizer from the Binaryen project. It receives a complete WASM module and transforms its instructions without returning to the original Rust source.

The first attempt exposes a feature mismatch:

$ wasm-opt -Oz pkg/pixeledit_core_bg.wasm -o pkg/pixeledit_final.wasm
[wasm-validator error in function 2] unexpected false: Bulk memory
operations require bulk memory [--enable-bulk-memory], on
(memory.copy
 (local.get $2)
 (local.get $0)
 (local.get $3)
)

The module contains memory.copy from the bulk-memory extension, and this version of wasm-opt must be told explicitly to accept that feature. Once the feature flags are present, validation and optimization complete:

$ wasm-opt -Oz \
      --enable-bulk-memory --enable-sign-ext --enable-mutable-globals \
      --enable-nontrapping-float-to-int --enable-reference-types \
      pkg/pixeledit_core_bg.wasm -o pkg/pixeledit_final.wasm
$ stat -c %s pkg/pixeledit_final.wasm
14873

wasm-opt -Oz removes another 765 bytes, or 4.9%. This is useful, but wasm-bindgen has already removed the main temporary layer.

The Final Pipeline Almost Erases Profile Differences

The unprocessed Cargo binary showed a substantial difference between the standard release build and the slim-s and slim-z profiles. To determine whether that difference survives into the shipped module, we run all three through the same wasm-bindgen and wasm-opt pipeline:

profile     after-bindgen      after-opt
release             21411          15483
slim-s              15638          14873
slim-z              15935          15123

Before the browser pipeline, the release build occupied 37832 bytes and slim-s occupied 31351. After the pipeline, the gap contracts to 610 bytes: 15483 versus 14873, about 4% of the final module.

The reason follows from the earlier breakdown. wasm-bindgen removes its own metadata and newly dead code, while wasm-opt optimizes the transformed module again. The Cargo profile can affect only the differences that survive both stages.

Alongside the .wasm file, the browser loads 5791 bytes of uncompressed JavaScript glue. It is not included in the WASM sizes above, but it is part of the total download budget. Optimizing one file does not replace measuring the complete shipped package.

The Allocator Remains Because of the Memory Boundary

After strip removes function names, twiggy sees only anonymous entries such as code[7]. For analysis, we can build a diagnostic variant that removes DWARF, retains the name section, and asks wasm-opt not to discard it.

This module is larger than the production build by exactly the cost of those names, but it reveals which functions own the remaining bytes:

$ twiggy top -n 12 pkg-probe/final_named.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼─────────────────────────────────────────────
          4869 ┊    24.62% ┊ <dlmalloc::dlmalloc::Dlmalloc<...>>::malloc
          3627 ┊    18.34% ┊ "function names" subsection
          1414 ┊     7.15% ┊ <core::fmt::Formatter>::pad
           866 ┊     4.38% ┊ __rustc::__rust_realloc
           805 ┊     4.07% ┊ <dlmalloc::dlmalloc::Dlmalloc<...>>::free
           797 ┊     4.03% ┊ data segment ".rodata"
           740 ┊     3.74% ┊ __externref_table_alloc
           529 ┊     2.67% ┊ <dlmalloc::...>::dispose_chunk
           510 ┊     2.58% ┊ core::fmt::write
           389 ┊     1.97% ┊ <dlmalloc::...>::unlink_chunk
           381 ┊     1.93% ┊ std::panicking::panic_with_hook
           362 ┊     1.83% ┊ <dlmalloc::...>::memalign
          4489 ┊    22.70% ┊ ... and 87 more.
         19778 ┊   100.00% ┊ Σ [99 Total Rows]

The name section occupies 3627 bytes only in this diagnostic module and is not shipped in production. Among executable functions, the dlmalloc family dominates: malloc, realloc, free, and their supporting operations occupy about 7.8 KB together.

The reason lies not in the filter formulas but in their &mut [u8] interface.

An ABI, or application binary interface, defines how two sides pass arguments, results, and control at the memory level. A JavaScript array and a Rust slice have different representations, so the glue needs an explicit copying protocol.

In the interface generated for &mut [u8], JavaScript cannot pass an existing Uint8Array directly as a Rust pointer. The glue calls the exported __wbindgen_malloc, allocates space in WASM linear memory, copies the pixels into it, runs the filter, and transfers the result back.

The allocator occupies a large amount of space, but it is not accidental waste. The chosen export contract keeps it alive. Meaningfully reducing this part requires changing the memory boundary, not adding another optimizer flag.

panic=abort Does Not Remove Reachable Formatting

The diagnostic module still contains Formatter::pad, fmt::write, and panic_with_hook even though the filters have no reachable out-of-bounds indexing and the profile uses panic = "abort".

twiggy paths shows the reference chain retaining the largest formatting function:

$ twiggy paths pkg-probe/final_named.wasm --regex '.*Formatter.*pad.*'
 Shallow Bytes │ Shallow % │ Retaining Paths
───────────────┼───────────┼──────────────────────────────────────
          1414 ┊     7.15% ┊ <core::fmt::Formatter>::pad
               ┊           ┊   ⬑ <&str as core::fmt::Display>::fmt
               ┊           ┊       ⬑ elem[0]
               ┊           ┊           ⬑ table[0]
               ┊           ┊   ⬑ <core::cell::BorrowMutError as core::fmt::Display>::fmt
               ┊           ┊       ⬑ elem[0]
               ┊           ┊           ⬑ table[0]

Both Display implementations remain reachable through table[0].

A WebAssembly function table stores references for indirect calls: the calling instruction selects a function by index at runtime. The optimizer cannot always prove which entry will be selected, so it must retain every function that may be reachable.

The wasm-bindgen glue uses this formatting to turn internal errors, including BorrowMutError, into JavaScript exception text. Because the call is indirect, the optimizer cannot safely remove Formatter::pad.

The zero-byte result from panic = "abort" now has a precise explanation. The setting removes stack unwinding, which was already absent on this WASM target. The remaining functions are retained by a different route: the function table used by the JavaScript glue.

Optimize the Module You Actually Ship

The complete size progression is:

ArtifactSizeChange
debug after cargo build2629023 bytes-
release37832 bytes-98.6%
slim-s profile31351 bytes-17.1%
after wasm-bindgen15638 bytes-50.1%
after wasm-opt -Oz14873 bytes-4.9%

The debug file really is 176.8 times larger than the final WASM module. But that ratio primarily describes the removal of debugging information. Starting from a proper release build reduces the remaining opportunity to 2.54 times, and the profile settings buy only 610 bytes after the complete pipeline.

The correct sequence therefore follows the contents of the file, not the popularity of a flag. Exclude the debug artifact first, pass the module through the required wasm-bindgen stage, measure the result after wasm-opt, and only then compare Cargo profiles.

opt-level = "z" can enlarge a particular binary. lto can save two bytes. panic = "abort" can change nothing. strip can help the initial Cargo module, but the browser pipeline will later repeat part of that work.

The largest remaining functions may be the cost of an interface rather than a failure of optimization. In this module, transferring &mut [u8] between two memory worlds retains almost half of the executable code. Reducing that layer requires changing the ABI, not adding another item to a checklist.

Appendix: Full Source Files

pixeledit-core/src/lib.rs — 96 lines

//! pixeledit core: real pixel filters over an RGBA byte buffer,
//! exported to the browser through wasm-bindgen.
//!
//! Every function walks the buffer as complete RGBA quads via
//! `chunks_exact_mut(4)`: a trailing partial pixel is ignored instead
//! of panicking, so no bounds-check panic path is ever reachable here.

use wasm_bindgen::prelude::wasm_bindgen;

/// Integer Rec.601 luma weights, scaled by 256 (77 + 150 + 29 = 256).
const LUMA_R: u16 = 77;
const LUMA_G: u16 = 150;
const LUMA_B: u16 = 29;

/// Convert an RGBA buffer to grayscale in place. Alpha is untouched.
#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
    for px in pixels.chunks_exact_mut(4) {
        let r = u16::from(px[0]);
        let g = u16::from(px[1]);
        let b = u16::from(px[2]);
        let luma = ((r * LUMA_R + g * LUMA_G + b * LUMA_B) >> 8) as u8;
        px[0] = luma;
        px[1] = luma;
        px[2] = luma;
    }
}

/// Invert the color channels of an RGBA buffer in place.
#[wasm_bindgen]
pub fn invert(pixels: &mut [u8]) {
    for px in pixels.chunks_exact_mut(4) {
        px[0] = 255 - px[0];
        px[1] = 255 - px[1];
        px[2] = 255 - px[2];
    }
}

/// Add `delta` to every color channel, saturating at 0 and 255.
#[wasm_bindgen]
pub fn brightness(pixels: &mut [u8], delta: i32) {
    for px in pixels.chunks_exact_mut(4) {
        for channel in &mut px[..3] {
            let value = i32::from(*channel) + delta;
            *channel = value.clamp(0, 255) as u8;
        }
    }
}

#[cfg(test)]
mod tests {
    use anyhow::{Result, ensure};

    use super::{brightness, grayscale, invert};

    #[test]
    fn grayscale_flattens_channels() -> Result<()> {
        let mut px = [200u8, 100, 50, 255];
        grayscale(&mut px);
        ensure!(px[0] == px[1]);
        ensure!(px[1] == px[2]);
        ensure!(px[3] == 255);
        // (200*77 + 100*150 + 50*29) >> 8 = 31850 >> 8 = 124
        ensure!(px[0] == 124);
        Ok(())
    }

    #[test]
    fn invert_is_involutive() -> Result<()> {
        let original = [10u8, 20, 30, 40];
        let mut px = original;
        invert(&mut px);
        ensure!(px == [245, 235, 225, 40]);
        invert(&mut px);
        ensure!(px == original);
        Ok(())
    }

    #[test]
    fn brightness_saturates() -> Result<()> {
        let mut px = [250u8, 5, 128, 7];
        brightness(&mut px, 100);
        ensure!(px == [255, 105, 228, 7]);
        brightness(&mut px, -300);
        ensure!(px == [0, 0, 0, 7]);
        Ok(())
    }

    #[test]
    fn partial_pixel_is_ignored() -> Result<()> {
        let mut buf = [1u8, 2, 3, 4, 9, 9, 9];
        invert(&mut buf);
        ensure!(buf[4..] == [9, 9, 9]);
        Ok(())
    }
}

pixeledit-core/Cargo.toml — 38 lines

[package]
name = "pixeledit-core"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
# Pinned exactly: the wasm-bindgen CLI version must match this one.
wasm-bindgen = "=0.2.126"

[dev-dependencies]
anyhow = "1"

# Stage 02: stock cargo release, untouched.
# (opt-level 3, no LTO, 16 codegen units, panic = unwind)

# Stage 03, candidate A: size-oriented opt-level "s".
[profile.slim-s]
inherits = "release"
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = true

# Stage 03, candidate B: the same diet with opt-level "z".
[profile.slim-z]
inherits = "slim-s"
opt-level = "z"

# Stage 05 helper: the winning diet with the name section kept, so
# twiggy can attribute bytes to real function names. Only DWARF goes:
# `strip = true` would also drop the names and blind the autopsy.
[profile.probe]
inherits = "slim-s"
strip = "debuginfo"
Newsletter

New playgrounds in your inbox

Get an email when a new playground drops, plus the occasional engineering deep-dive. No spam, unsubscribe anytime.