Nine Rules for Running Rust on the Web and on Embedded

Practical Lessons from Porting range-set-blaze to no_std and WASM

Crab running on a microcontroller — Source:

I recommend Rust when you want the speed of C++ and the memory-safety of Python. On top of that, with Rust you can build on more than 100,000 software libraries. In addition, Rust offers the potential of running your code not just on a conventional computer, but also inside a web page or even on a robot.

Running “almost everywhere”, however, comes with complications. This article is for Rust programmers who want to mitigate these complications. (It may also be of interest to those who want to see Rust’s “run almost everywhere” story.)

First complication: Web pages and the embedded processors of robots don’t support general file IO. If your project is mostly about reading and writing files, it’s not a good candidate for running on a robot, other embedded processor, or a web page.

Second complication: Porting code to run almost everywhere requires many steps and choices. Navigating these choices can be time consuming. Missing a step can lead to failure. This article aims to reduce this second complication by offering these nine rules, which we’ll later explore in detail:

Mark your or as no_std.Use the built-in “crate alloc” if you can.Switch to “no std” dependances.Create std and alloc features and make your std-only functions optional.Build your project for WASM. Use cargo tree to get it working.Create WASM tests and a WASM demo.[Optional] Build your project for embedded.[Optional] Create a single embedded test and an embedded demo.Finish up with CI testing, Cargo.toml metadata, and an updated

Following these rules will help you create very fast and memory-safe code that runs everywhere from a PC, to a smart phone web page (demo), to a robot. The code can be very tiny and can leverage the huge Rust crate library.

To illustrate the rules, we’ll port the range-set-blaze crate to run inside web pages — WASM — and on microcontrollers — embedded. (This crate manipulates sets of “clumpy” integers. A user of the crate requested the port.)

A port to WASM and embedded requires that you avoid using Rust’s standard library, “std”. Converting to “no std” was both easier and harder than I expected. Easier because you can still use Vec and String. Harder, mostly because of testing. Based on my experience with range-set-blaze, here are the decisions, described one at a time, that I recommend. To avoid wishy-washiness, I’ll express these recommendations as rules.

Rule 1: Mark your or as no_std.

Aside: First, use Git to create a new branch for your project. That way, if things don’t work out, you can easily undo all changes.

Mark the top of with:

#![cfg_attr(not(test), no_std)]

This tells the Rust compiler not to include the standard library, except when testing.

Aside 1: My project is a library project with a I believe the steps for a binary project with a are about the same, but I haven’t tested them.Aside 2: We’ll talk much more about code testing in later rules.

Adding the “no_std” line to range-set-blaze’s, causes 40 compiler problems, most of this form:

Fix some of these by changing, “std::” to “core::” in your main code (not in test code). For range-set-blaze, this reduces the number of problems from 40 to 12. This fix helps because many items, such as std::cmp::max, are also available as core::cmp::max.

Sadly, items such as Vec and Box cannot be in core because they need to allocate memory. Happily, if you’re willing to support memory allocation, you can still use them.

Rule 2: Use the built-in “crate alloc” if you can.

Should you allow your crate to allocate memory? For WASM you should. For many embedded applications, you also should. For some embedded applications, however, you should not. If you decide to allow memory allocation, then at the top of add:

extern crate alloc;

You can now add lines such as these to get access to many memory-allocated items:

extern crate alloc;

use alloc::boxed::Box;
use alloc::collections::btree_map;
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use alloc::{format, string::String};
use alloc::vec;

With range-set-blaze, this reduces the number of problems from 12 to two. We’ll fix these in Rule 3.

Aside: What if you are writing for an embedded environment that can’t use memory allocation and are having problems with, for example, Vec. You may be able to re-write. For example, you may be able to use an array in place of a vector. If that doesn’t work, take a look at the other rules. If nothing works, you may not be able to port your crate to no_std.

Rule 3: Switch to “no std” dependances.

The Rust compiler complains if your project used a crate that puts “std” functions in your code. Sometimes, you can search and find alternative “no_std” crates. For example, the popular thiserror crate injects “std” into your code. However, the community has created alternatives that do not.

In the case of range-set-blaze, the two remaining problems relate to crate gen_ops — a wonderful crate for defining operators such as “+” and “&” conveniently. Version 0.3.0 of gen_ops did not fully support “no std”. Version 0.4.0, however, does. I updated my dependencies in Cargo.toml and improved my “no std” compatibility.

I can now run these commands:

cargo check # check that compiles as no_std
cargo test # check that tests, using std, still pass

The command cargo check confirms that my crate isn’t directly using the standard library. The command cargo test confirms that my tests (which still use the standard library) continue to pass. If your crate still doesn’t compile, take a look at the next rule.

Rule 4: Create std and alloc features and make your std-only functions optional.

Embedded processors generally don’t support reading and writing files. Likewise, WASM doesn’t yet fully support files. While you can find some file-related “no std” crates, none seem comprehensive. So, if file IO is central to your crate, porting to WASM and embedded may not be practical.

However, if file IO — or any other std-only function — is merely incidental to your crate, you can make that function optional via a “std” feature. Here is how:

Add this section to your Cargo.toml:

resolver = “2” # the default for Rust 2021+

default = [“std”]
std = []
alloc = []

This says that your crate now has two features, “std” and “alloc”. By default, the compiler should use “std”.

At the top of your, replace:

#![cfg_attr(not(test), no_std)]


#![cfg_attr(not(feature = “std”), no_std)]

This says that if you do not apply the “std” feature, the compiler should compile without the standard library.

On the line before any code that is std-only, placed #[cfg(feature = “std”)]. For example, here we define a function that creates a RangeSetBlaze struct based on the contents of a file:

#[cfg(feature = “std”)]
use std::fs::File;
#[cfg(feature = “std”)]
use std::io::{self, BufRead};
#[cfg(feature = “std”)]
use std::path::Path;

#[cfg(feature = “std”)]
pub fn demo_read_ranges_from_file<P, T>(path: P) -> io::Result<RangeSetBlaze<T>>
P: AsRef<Path>,
T: FromStr + Integer,
//…code not shown

To check the “std” and “alloc” features, do this:

cargo check # std
cargo check –features alloc –no-default-features

We can test “std” with

cargo testAside: Surprisingly, cargo test –features alloc –no-default-features does not test “alloc”. That is because tests require threads, allocation, and other things that may not be available​ in no_std so cargo always runs regular tests as “std”.

At this point we’re checking both “std” and “alloc”, so can we assume that our library will work with WASM and embedded. No! Generally, Nothing works without being tested. Specifically, we might be depending on crates that use “std” code internally. To find these issues, we must test in the WASM and embedded environments.

Rule 5: Build your project for WASM. Use cargo tree to get it working.

Install the WASM cross compiler and check your project with these commands:

rustup target add wasm32-unknown-unknown # only need to do this once
# may find issues
cargo check –target wasm32-unknown-unknown –features alloc –no-default-features

When I do this on range-set-blaze, it complains that the getrandom crate doesn’t work with WASM. On the one hand, I’m not surprised that WASM does not fully support random numbers. On the other hand, I am surprised because my project doesn’t directly depend on getrandom. To find the indirect dependency, I use cargo tree. I discover that my project depends on crate rand which depends on getrandom. Here is the cargo tree command to use:

cargo tree –edges no-dev –format “{p} {f}” –features alloc –no-default-features

The command outputs both crates and the features they use:

range-set-blaze v0.1.6 (O:ProjectsSciencewasmetcwasm3) alloc
├── gen_ops v0.4.0
├── itertools v0.10.5 default,use_alloc,use_std
│ └── either v1.8.1 use_std
├── num-integer v0.1.45 default,std
│ └── num-traits v0.2.15 default,std
│ [build-dependencies]
│ └── autocfg v1.1.0
│ [build-dependencies]
│ └── autocfg v1.1.0
├── num-traits v0.2.15 default,std (*)
├── rand v0.8.5 alloc,default,getrandom,libc,rand_chacha,std,std_rng
│ ├── rand_chacha v0.3.1 std
│ │ ├── ppv-lite86 v0.2.17 simd,std
│ │ └── rand_core v0.6.4 alloc,getrandom,std
│ │ └── getrandom v0.2.9 std
│ │ └── cfg-if v1.0.0

The output shows that range-set-blaze depends on rand. Also, it shows that rand depends on getrandom with its “std” feature.

I read the getrandom documentation and learn that its “js” feature supports WASM. So, how do we tell rand to use getrandom/js, but only when we compile with our “alloc” feature? We update our Cargo.toml like so:

default = [“std”]
std = [“getrandom/std”]
alloc = [“getrandom/js”]

# …
getrandom = “0.2.10”

This says that our “std” feature depends on getrandom’s “std” feature. Our “alloc” feature, however, should use the js feature of getrandom.

This now works:

cargo check –target wasm32-unknown-unknown –features alloc –no-default-features

So, we have WASM compiling, but what about testing WASM?

Rule 6: Create WASM tests and a WASM demo.

Let’s put the WASM version to work, first with tests and then with a demo web page.

Create WASM tests in tests/

You can test on WASM almost as easily as you can test natively. We do this by having the original tests only run natively while an almost duplicate set of tests run on WASM. Here are the steps based on The wasm-bindgen Guide:

Do cargo install wasm-bindgen-cliCopy your current integration tests from, for example, tests/ to tests/ (Recall that in Rust, integration tests are tests that live outside the src directory and that see only the public methods of a project.)At the top of tests/, remove #![cfg(test)] and add
#![cfg(target_arch = “wasm32”)]
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);In, replace all#[test]’s to #[wasm_bindgen_test]’s.Everywhere you have #![cfg(test)] (typically, intests/ and src/ add the additional line: #![cfg(not(target_arch = “wasm32”))]In your, Cargo.toml, change your[dev-dependencies] (if any) to [target.’cfg(not(target_arch = “wasm32”))’.dev-dependencies]In your, Cargo.toml, add a section:[target.’cfg(target_arch = “wasm32”)’.dev-dependencies]
wasm-bindgen-test = “0.3.37”

With all this set up, native tests, cargo test, should still work. If you don’t have the Chrome browser installed, install it. Now try to run the WASM tests with:

wasm-pack test –chrome –headless –features alloc –no-default-features

It will likely fail because your WASM tests use dependencies that haven’t or can’t be put in Cargo.toml. Go through each issue and either:

Add the needed dependencies to Cargo.toml’s [target.’cfg(target_arch = “wasm32”)’.dev-dependencies]section, orRemove the tests from tests/

For range-set-blaze, I removed all WASM tests related to testing the package’s benchmarking framework. These tests will still be run on the native side. Some useful tests in needed crate syntactic-for, so I added it to Cargo.toml, under [target.’cfg(target_arch = “wasm32”)’.dev-dependencies]. With this fixed, all 59 WASM tests run and pass.

Aside: If your project includes an examples folder, you may need create a native module inside your example and a wasm module. See this range-set-blaze file for an “example” example of how to do this.

Create a WASM demo in tests/wasm-demo

Part of the fun of supporting WASM is that you can demo your Rust code in a web page. Here is a web demo of range-set-blaze.

Follow these steps to create your own web demo:

In your project’s main Cargo.toml file, define a workspace and add tests/wasm-demo to it:

members = [“.”, “tests/wasm-demo”]

In your tests folder, create a test/wasm-demo subfolder.

It should contain a new Cargo.toml like this (change range-set-blaze to the name of your project):

name = “wasm-demo”
version = “0.1.0”
edition = “2021”

crate-type = [“cdylib”]

wasm-bindgen = “0.2”
range-set-blaze = { path = “../..”, features = [“alloc”], default-features = false}

Also, create a file tests/wasm-demo/src/ Here is mine:

extern crate alloc;
use alloc::{string::ToString, vec::Vec};
use range_set_blaze::RangeSetBlaze;
use wasm_bindgen::prelude::*;

pub fn disjoint_intervals(input: Vec<i32>) -> JsValue {
let set: RangeSetBlaze<_> = input.into_iter().collect();
let s = set.to_string();

This file defines a function called disjoint_intervals that takes a vector of integers as input, for example, 100,103,101,102,-3,-4. Using the range-set-blaze package, the function returns a string of the integers as sorted, disjoint ranges, for example, -4..=-3, 100..=103.

As your final step, create file tests/wasm-demo/index.html. Mine uses a little JavaScript to accept a list of integers and then call the Rust WASM function disjoint_intervals.

<!DOCTYPE html>
<h2>Rust WASM RangeSetBlaze Demo</h2>
<p>Enter a list of comma-separated integers:</p>
<input id=”inputData” type=”text” value=”100,103,101,102,-3,-4″ oninput=”callWasmFunction()”>
<p id=”output”></p>
<script type=”module”>
import init, { disjoint_intervals } from ‘./pkg/wasm_demo.js’;

function callWasmFunction() {
let inputData = document.getElementById(“inputData”).value;
let data = inputData.split(‘,’).map(x => x.trim() === “” ? NaN : Number(x)).filter(n => !isNaN(n));
const typedArray = Int32Array.from(data);
let result = disjoint_intervals(typedArray);
document.getElementById(“output”).innerHTML = result;
window.callWasmFunction = callWasmFunction;

To run the demo locally, first move your terminal to tests/wasm-demo. Then do:

# from tests/wasm-demo
wasm-pack build –target web

Next, start a local web server and view the page. I use the Live Preview extension to VS Code. Many people use python -m http.server. The range-set-blaze demo looks like this (also available, live on GitHub):

I find watching my Rust project run in a web page very gratifying. If WASM-compatibility is all you are looking for, you can skip to Rule 9.

Rule 7: Build your project for embedded.

If you want to take your project a step beyond WASM, follow this rule and the next.

Be sure you move your terminal back to your project’s home directory. Then, install thumbv7m-none-eabi, a popular embedded processor, and check your project with these commands:

# from project’s home directory
rustup target add thumbv7m-none-eabi # only need to do this once
# will likely find issues
cargo check –target thumbv7m-none-eabi –features alloc –no-default-features

When I do this on range-set-blaze, I get errors related to four sets of dependencies:

thiserror — My project depended on this crate but didn’t actually use it. I removed the dependency.rand and getrandom — My project only needs random numbers for native testing, so I moved the dependency to [target.’cfg(not(target_arch = “wasm32”))’.dev-dependencies]. I also updated my main and testing code.itertools, num-traits, and num-integer — These crates offer features for “std” and “alloc”. I updated Cargo.toml like so:…
default = [“std”]
std = [“itertools/use_std”, “num-traits/std”, “num-integer/std”]
alloc = [“itertools/use_alloc”, “num-traits”, “num-integer”]

itertools = { version = “0.10.1”, optional = true, default-features = false }
num-integer = { version = “0.1.44”, optional = true, default-features = false }
num-traits = { version = “0.2.15”, optional = true, default-features = false }
gen_ops = “0.4.0”

[target.’cfg(not(target_arch = “wasm32”))’.dev-dependencies]
rand = “0.8.4”

How did I know which feature of which dependancies to use? Understanding the features of a crate such as itertools requires reading its documentation and (often) going to its GitHub repository and reading its Cargo.toml. You should also use cargo tree to check that you are getting the desire feature from each dependency. For example, this use of cargo tree shows that for a default compile, I get the “std” features of range-set-blaze, num-integer, and num-traits and the “use-std” features of itertools and either:

cargo tree –edges no-dev –format “{p} {f}”range-set-blaze v0.1.6 (O:ProjectsSciencewasmetcwasm4) default,itertools,num-integer,num-traits,std
├── gen_ops v0.4.0
├── itertools v0.10.5 use_alloc,use_std
│ └── either v1.8.1 use_std
├── num-integer v0.1.45 std
│ └── num-traits v0.2.15 std
│ [build-dependencies]
│ └── autocfg v1.1.0
│ [build-dependencies]
│ └── autocfg v1.1.0
└── num-traits v0.2.15 std (*)

And this shows that for a –features alloc –no-default-feature compile, I get the desired “use_alloc” feature of itertools and “no default” version of the other dependances:

cargo tree –edges no-dev –format “{p} {f}” –features alloc –no-default-featuresrange-set-blaze v0.1.6 (O:ProjectsSciencewasmetcwasm4) alloc,itertools,num-integer,num-traits
├── gen_ops v0.4.0
├── itertools v0.10.5 use_alloc
│ └── either v1.8.1
├── num-integer v0.1.45
│ └── num-traits v0.2.15
│ [build-dependencies]
│ └── autocfg v1.1.0
│ [build-dependencies]
│ └── autocfg v1.1.0
└── num-traits v0.2.15 (*)

When you think you have everything working, use these commands to check/test native, WASM, and embedded:

# test native
cargo test
cargo test –features alloc –no-default-features
# check and test WASM
cargo check –target wasm32-unknown-unknown –features alloc –no-default-features
wasm-pack test –chrome –headless –features alloc –no-default-features
# check embedded
cargo check –target thumbv7m-none-eabi –features alloc –no-default-features

These check embedded, but what about testing embedded?

Rule 8: Create a single embedded test and an embedded demo.

Let’s put our embedded feature to work by creating a combined test and demo. We will run it on an emulator called QEMU.

Testing native Rust is easy. Testing WASM Rust is OK. Testing embedded Rust is hard. We will do only a single, simple test.

Aside 1: For more, about running and emulating embedded Rust see: The Embedded Rust Book.Aside 2: For ideas on a more complete test framework for embedded Rust, see defmt-test. Sadly, I couldn’t figure out how to get it to run under emulation. The cortex-m/testsuite project uses a fork of defmt-test and can run under emulation but doesn’t offer a stand-alone testing crate and requires three additional (sub)projects.Aside 3: One embedded test is infinitely better than no tests. We’ll do the rest of our testing at the native and WASM level.

We will create the embedded test and demo inside our current tests folder. The files will be:

├── .cargo
│ └── config.toml
├── Cargo.toml
├── memory.x
└── src

Here are the steps to creating the files and setting things up.

Install the QEMU emulator. On Windows, this involves running an installer and then manually adding “C:Program Filesqemu” to your path.

2. Create a tests/embedded/Cargo.toml that depends on your local project with “no default features” and “alloc”. Here is mine:

edition = “2021”
name = “embedded”
version = “0.1.0”

alloc-cortex-m = “0.4.4”
cortex-m = “0.6.0”
cortex-m-rt = “0.6.10”
cortex-m-semihosting = “0.3.3”
panic-halt = “0.2.0”# reference your local project here
range-set-blaze = { path = “../..”, features = [“alloc”], default-features = false }

name = “embedded”
test = false
bench = false

3. Create a file tests/embedded/src/ Put your test code after the “test goes here” comment. Here is my file:

// based on
// and

extern crate alloc;
use alloc::string::ToString;
use alloc_cortex_m::CortexMHeap;
use core::{alloc::Layout, iter::FromIterator};
use cortex_m::asm;
use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};
use panic_halt as _;
use range_set_blaze::RangeSetBlaze;

static ALLOCATOR: CortexMHeap = CortexMHeap::empty();
const HEAP_SIZE: usize = 1024; // in bytes

fn main() -> ! {
unsafe { ALLOCATOR.init(cortex_m_rt::heap_start() as usize, HEAP_SIZE) }

// test goes here
let range_set_blaze = RangeSetBlaze::from_iter([100, 103, 101, 102, -3, -4]);
assert!(range_set_blaze.to_string() == “-4..=-3, 100..=103”);
hprintln!(“{:?}”, range_set_blaze.to_string()).unwrap();

// exit QEMU/ NOTE do not run this on hardware; it can corrupt OpenOCD state
loop {}

fn alloc_error(_layout: Layout) -> ! {
loop {}

4. Copy and memory.x from cortex-m-quickstart’s GitHub to tests/embedded/.

5. Create a tests/embedded/.cargo/config.toml containing:

runner = “qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel”

target = “thumbv7m-none-eabi”

6. Update your project’s main Cargo.toml by adding tests/embedded to your workspace:

members = [“.”, “tests/wasm-demo”, “tests/embedded”]

With this setup, you are almost ready to run emulated embedded. Next, get your terminal in position and the compiler set to nightly:

# Be sure qemu is on path, e.g., set PATH=”C:Program Filesqemu”;%PATH%
cd tests/embedded
rustup override set nightly # to support #![feature(alloc_error_handler)]

You can now use cargo check, cargo build, and cargo run on the demo app. For example:

cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel O:ProjectsSciencewasmetcwasm4targetthumbv7m-none-eabidebugembedded`
Timer with period zero, disabling
“-4..=-3, 100..=103”

When you get this working, you will have successfully run your project on an (emulated) microcontroller! If you have problems with the setup, double check these instructions. If that doesn’t work, take a look at The Embedded Rust Book.

When you are done, be sure to set your compiler back to stable:

rustup override set stable

Rule 9: Finish up with CI testing, Cargo.toml metadata, and an updated

CI Testing

We’re almost done, but we must make sure that everything that works today will work tomorrow. That’s the job of CI (continuous integration) testing.

I set my CI tests to run every check in and once every month. If on GitHub, create a file .github/workflows/tests.yml:

name: test

schedule: # run every month
– cron: ‘0 0 1 * *’

name: Test Rust
runs-on: ubuntu-latest
– name: Checkout
uses: actions/checkout@v3
– name: Setup Rust
uses: dtolnay/rust-toolchain@master
toolchain: stable
– name: Setup WASM
uses: jetli/wasm-pack-action@v0.4.0
– name: Test Native & WASM
run: |
cargo clippy –verbose –all-targets –all-features — -D warnings
cargo test –verbose
cargo test –features alloc –no-default-features –verbose
wasm-pack test –chrome –headless –features alloc –no-default-features –verbose
– name: Setup and check Embedded
run: |
rustup target add thumbv7m-none-eabi
cargo check –target thumbv7m-none-eabi –features alloc –no-default-features
rustup override set nightly
rustup target add thumbv7m-none-eabi
cargo check –target thumbv7m-none-eabi –features alloc –no-default-features
sudo apt-get update && sudo apt-get install qemu qemu-system-arm
– name: Test Embedded (in nightly)
timeout-minutes: 3
run: |
cd tests/embedded
cargo run

If you are only doing WASM, you can leave off the last two steps that relate to embedded.

Aside: Why does the last test say timeout-minutes: 3? Because a failed embedded test doesn’t return with failure. Instead, it goes into an endless loop. We catch this with the timeout.


Rust allows you to mark your code as working on particular architectures and environments. The convention is to use keyword and category metadata. Specifically, add these keywords and categories to your Cargo.toml as appropriate:

keywords = [
categories = [

You should also update your to tell people that you support WASM and embedded. Here is what I added:

The crate supports no_std, WASM, and embedded projects:

range-set-blaze = { features = [“alloc”], default-features = false, version=VERSION }


*Relace VERSION with the current version.

So, there you have it: nine rules for WASM and no_std ports in Rust. Rust is a great language for native, WASM, and embedded programming. It offers speed, safety, and access to thousands of useful crates. Follow these nine rules to run your own Rust code almost everywhere.

Aside: If you’re interested in future articles, please follow me on Medium. I write on scientific programming in Rust and Python, machine learning, and statistics. I tend to write about one article per month.

Nine Rules for Running Rust on the Web and on Embedded was originally published in Towards Data Science on Medium, where people are continuing the conversation by highlighting and responding to this story.


Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox, every month.

We don’t spam!

Leave a Comment

Scroll to Top