JVM went the other way: arbitrary control flow plus a verifier that does dataflow with type merges across joins. That's expensive enough that JVMs do it once at class load and cache the verified state. WASM specifically didn't want that bill; fast startup was a hard requirement.
So the prefix/postfix debate elsewhere in the thread is downstream of this. The encoded form is postfix because that's what trivially admits a linear validator; the textual LISP form is sugar for the same expression trees inside a typed frame. dup isn't missing for aesthetic reasons either: local.tee n followed by local.get n already gives you dup-equivalence through typed locals, and any stack op that didn't reduce to typed locals would either duplicate what locals already do, or break the validator's linearity guarantee.
The way I see it, the difference between register and stack vms is all about the instruction encoding. Register VMs have fatter instructions in exchange for needing fewer LOAD and STORE operations. Despite the name, register VMs also have a stack.
It has failed to deliver that - so much is clear now. You rarely see any awesome success story shown with regard to WASM nowadays. What happened to the old promises? "Electron will be SUPER fast thanks to WASM" or "use any language, WASM unifies it all for the larger browser ecosystem".
It feels as if WASM is on a step towards exctinction. Sure, it is mentioned, it is used, but let's be honest - only few people really use it. And that won't change either.
Very well articulated and concise critique by somebody who seems to have a great amount of knowledge and experience with the topics.
> In textual Wasm, for example, they are instead represented in a LISP-like notation – not any less or more efficient
The Text format, at least when it comes to instructions, it 1 to 1 with the binary format. The LISP-like syntax is mainly just syntax sugar[1].
‘(’ plaininstr instrs ‘)’ ≡ instrs plaininstr
So (in theory, as far as I understand it) you can just do `(local.get 2 local.get 0 local.get 1)` to mean `local.get 0 local.get 1 local.get 2`, and it works for (almost) any instruction.Unfortunately, in my limited testing, tools like `wat2wasm` and Binaryen's `wasm-as` don't seem to adhere to (my perhaps faulty understanding of) the spec, and demand all instructions in a folded block be folded and have the "correct" amount of arguments, which makes Binaryen do weird things like
(return
(tuple.make ;; Binaryen only pseudoinstruction
(local.get 0) ;; or w/e expression
(local.get 1) ;; or w/e expression
)
)
when this is perfectly valid local.get 0
local.get 1
return
tl;dr: the LISP syntax is just syntax sugar. The textual format is as "stack-like" as the binary format.Edit: An example that is easily done with the stack syntax and not with lisp syntax is the following:
call function_that_returns_multivalue
local.set 2 ;; last return
local.set 1 ;;
local.set 0 ;; first return
In LISP syntax this would be (local.set 0
(local.set 1
(local.set 2
(call function_that_returns_multivalue
( ;; whatever input paramters
)))))
I have not yet tried this with Binaryen but I doubt it flies.[1]: https://webassembly.github.io/spec/core/text/instructions.ht...
It can obviously do amazing things, but the expectation for it to do replace webdev frontend code was always a huge misconception. Though recent developments have made DOM access without a JavaScript translation layer possible, so that might change!
I'd say the hype is still very much alive.
Out of curiosity what do you think about this - in spite of the name, stack machines also have yet another stack. Ok I don't like that wording, but locals are basically the stack frames people know of from their computer arch class I think.
It doesn't change the fact that Wasm operations have to have the execution stack as one or more of the operands. Seems like a stack machines to me too, though I don't know more details on why the specific design of Wasm would make optimizing compilers harder to write than JVM as the article suggests (I think?).
public static void test() {
new Object();
}
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: pop
8: returnThere are some cool edge cases if you want to print a mismatched multi-value instruction sequence in the folded form (which WABT and wasm-tools again handle "correctly," but not identically to each other, and not particularly meaningfully).
https://raw.githubusercontent.com/soegaard/webracket/refs/he...
As a small example, here is a definition of `$car` which extracts the first value from a pair.
(func $car (type $Prim1)
(param $v (ref eq))
(result (ref eq))
(if (result (ref eq))
(ref.test (ref $Pair) (local.get $v))
(then (struct.get $Pair $a (ref.cast (ref $Pair) (local.get $v))))
(else (call $raise-pair-expected (local.get $v))
(unreachable))))Edit: Yep. In article referenced from the original: http://troubles.md/posts/wasm-is-not-a-stack-machine/
Double edit: Some of this has already been fixed in WASM: https://github.com/WebAssembly/multi-value
Not that you're technically wrong, but I think you're begging the question.
Stack-based languages/encodings, in a colloquial sense, are equated to postfix notation, e.g. `a b +` instead of the infix `a + b`. Both LISP and textual Wasm use prefix notation, e.g. `(+ a b)`. Neither of the three is any more foundational than the other -- all notations can encode all expression trees, and postfix and prefix notations in particular have the same coding efficiency.
So sure, the LISP syntax is sugar, but for what? It's not sugar for a stack program, because prefix notation in general can't represent an arbitrary stack program; it's sugar for a mathematical expression. Which is encoded in postfix notation in binary, sure, but that's just an implementation detail, and prefix notation could've been selected when Wasm was born with little adversarial consequences.
If not, I think the OP is making the same point we all are, any program can be translated for execution on any machine - so bringing it up in the blog seems weak, which I agree with.
It is explicity sugar for the stack operations, per my reading of the spec.
I've used it to translate SQLite (with a few extensions) and, that I know of, it's been used (to varying degrees of success) to translate the MARISA trie library (C++), libghostty (Zig), zlib, Perl, and QuickJS.
More on-topic, I use a mix of an unevaluated expression stack and a stack-to-locals approach to translate Wasm.
April 27, 2026 Lobsters Hacker News
Everyone knows Wasm is a stack machine. Wikipedia says so, the official Wasm design specification says so, you get it. I thought so too.
That is, until I started writing Wasm code – not compiling for Wasm, but writing the instructions by hand. And I found out that there exists a major difference between Wasm and all other stack-based languages, that makes this claim misleading.
Let’s back up a bit. What is a stack machine, even?
Say you write a program in a high-level language, and at some point you want to calculate 2 * 3 + 5 * 7. Low-level languages don’t have a notion of compound expressions: they can only perform one operation at a time. So you need to do two multiplications, save their results, and then perform addition.
Many low-level languages, like x86 assembly, would represent these steps as follows:
a = 2b = 3c = a * bd = 5e = 7f = d * eg = c + fThis is called a register machine. You have variables (called registers), which can be used to store both persisted values and temporary results, and each instruction has form var1 = var2 op var3.
Other languages, like Forth or Hex Casting, use a stack for this purpose. The stack can store a sequence of values in an ordered manner, so that already computed subexpressions can lie around while you’re working on other parts. In a stack-based language, the same calculation would look like:
push(2)push(3)mul() – pops the last two values from the stack and pushes their productpush(5)push(7)mul()add()Note that there’s a similarity between the two programs: they have the same number of steps, and the corresponding steps perform the same operation. The major difference is that with a stack machine, the values operated upon are implicitly encoded in the program order, while the register machine always encodes indices.
We know always-shrinking lossless compression doesn’t exist, though, so what expression power is lost by making indices implicit? For simple expressions, not much. But when values are reused, the difference becomes clear.
Say you’re a compiler, and you’re asked to compile this program:
x = 1 + 2 + 3 + 4
y = x * x * x
With a register machine, you can do:
x as usual)tmp = x * xy = tmp * xA stack machine as described above, however, does not offer a way to refer to the same value twice: mul always multiplies two values on different positions in the stack. To enable this calculation, real stack machines introduce stack manipulation operations in addition to pure calculation. The one we’re looking for is called dup, and it _dup_licates the value on top of the stack:
x as usual)dup() – the stack now contains x, xdup() – the stack now contains x, x, xmul() – the stack now contains x, x*xmul() – the stack now contains x*(x*x)You might notice that the register machine calculated (x*x)*x, while the stack machine calculated x*(x*x). These two are the same thing for multiplication, but may be different for other operations. To fix this, we also need to introduce swap, which, as the name implies, swaps the two values on top of the stack:
x as usual)dup() – the stack now contains x, xdup() – the stack now contains x, x, xmul() – the stack now contains x, x*xswap() – the stack now contains x*x, xmul() – the stack now contains (x*x)*xIn practice, more operations are usually used to facilitate computation: over (copy second-last value to the top), 2dup (duplicate two values), drop (pop last value), rot (move third-last value to the top), etc.
From this perspective, stack machines can be seen as decoupling operations from indices they operate on. Whereas register machines always encode indices and pay a higher price when they’re redundant, stack machines encode them on an as-needed basis, but at the cost of a higher instruction count. If I wanted to be fancy, I’d say stack machines implement entropy-encoded compression for register machines.
If you look at JVM, a well-known stack machine Wikipedia compares WebAssembly to, you’ll find basically this exact list of bytecode instructions:
iaload, iastore, iconst.d2f, iadd.dup, dup_x1 (aka over), pop (aka drop), swap.JVM is not a pure stack machine: there are also instructions for accessing local variables, like iload and istore. But it’s possible to write powerful JVM programs without their use, and javac mostly only uses them for variables explicitly created by the Java programmer.
Now let’s look at the Wasm instruction set:
i32.load, i32.store, i32.const.f32.demote_f64, i32.add.drop, uhh, ???.Well, now isn’t that interesting? Wasm has plenty of instructions that receive arguments and place return values on the stack, but almost no instructions that can rearrange it – and, as far as I can tell, drop only exists because otherwise you wouldn’t have a way to ignore a function output.
Pretty much the only thing pure Wasm can do is evaluate simple expressions exactly as written in source code. An optimizing compiler can’t perform common subexpression elimination or optimize expr^2 to expr * expr without introducing new variables. The moment you need anything non-trivial, you have to reach for variables – and thus end up with a register machine, the “stack machine” illusion falling apart.
In my opinion, the right way to look at Wasm is as a register machine with operations generalized to compound expressions.
In binary Wasm, the expressions are encoded in Reverse Polish notation, which can be evaluated with a stack, but this is just an encoding. In textual Wasm, for example, they are instead represented in a LISP-like notation – not any less or more efficient. One can imagine a world where binary Wasm used prefix notation as well, with little impact; if I had to guess, postfix notation was preferred to simplify non-optimizing interpreters, or perhaps the experience with stack-based VMs was a tie-breaker.
This perspective is further confirmed by the fact that, until Wasm got the multi-value extension, control flow blocks pretty much couldn’t interact with the stack: values pushed onto the stack before if could not be accessed within the if body, and the if body could only return one value, so if was effectively just a ternary, and even values with a single consumer had to go through locals.
Does it really matter? Pretty much any machine can be converted to SSA, at which point the input format is not a consideration; and I suppose the simplicity of stack-based implementation was a good thing for Wasm adoption. But I think it’s fair to highlight that experience with stack-based VMs doesn’t translate well to Wasm, since it’s not quite a stack machine.
Soon after writing this post, I found this awesome post covering the same problem from a different, optimization-focused angle. Give it a read as well!