GoloScript and WebAssembly
Addendum: Optimizing the WASM Communication Protocol
This morning, my brain woke me up by reminding me of an article I had written a few years ago about how to exchange string data between the host and the WebAssembly guest: [Wazero Cookbook - Part One: WASM function & Host application](https://k33g.hashnode.dev/wazero-cookbook-part-one-wasm-function-host-application). And the method was better, so I needed to revisit the implementation of wasm support in GoloScript. But let me first explain the changes I've made.
In the previous article, we saw how to enable communication between a Go host and a WASM module using a 3-function protocol. This approach works well, but it can be optimized to reduce the number of function calls and eliminate the need for global variables.
This addendum presents a significant improvement to this protocol, inspired by WASM community best practices.
The Problem with the 3-Function Approach
Let's recall the original protocol:
// WASM module (guest)
func hello(ptr, size uint32) // Process and store result
func getResultPtr() uint32 // Return pointer
func getResultLen() uint32 // Return length
// Required global variables
var resultBuffer []byte
Drawbacks:
3 function calls to get one result
Global variables to store state
More complexity in the code
Performance overhead

The Optimized Solution: uint64 Encoding
The idea is simple yet powerful: encode both the pointer AND the length into a single uint64 value.
The Principle
A uint64 contains 64 bits. We can store:
Upper 32 bits → the pointer (uint32)
Lower 32 bits → the length (uint32)

Benefits
1 function call instead of 3 (66% reduction)
No global variables in the WASM module
Cleaner code and easier to maintain
Better performance through reduced FFI calls
Thread-safe by design (no shared state)

WASM-Side Implementation (Guest)
New Function Signature
// Before: 3 functions
//export hello
func hello(ptr, size uint32) {
// ...
resultBuffer = []byte(result) // Global!
}
//export getResultPtr
func getResultPtr() uint32 { ... }
//export getResultLen
func getResultLen() uint32 { ... }
// After: 1 function
//export hello
func hello(ptr, size uint32) uint64 {
// Read input
inputBytes := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
name := string(inputBytes)
// Process
result := "Hello, " + name + "!!!"
resultData := []byte(result)
// Get result pointer
resultPtr := uintptr(unsafe.Pointer(&resultData[0]))
resultLen := uint32(len(resultData))
// Encode: pointer in upper 32 bits, length in lower 32 bits
return (uint64(resultPtr) << 32) | uint64(resultLen)
}
The Encoding Technique
// Encoding
ptr := uint32(unsafePtr)
size := uint32(len(buffer))
// Shift pointer 32 bits to the left
// and combine with size using binary OR
return (uint64(ptr) << uint64(32)) | uint64(size)
Concrete Example:
ptr = 0x00001234 (4660 in decimal)
size = 0x000000FF (255 in decimal)
Step 1: Shift pointer
uint64(ptr) << 32
= 0x0000123400000000
Step 2: Combine with OR
0x0000123400000000
| 0x00000000000000FF
= 0x00001234000000FF
Host-Side Implementation (Go)
New Call Function
// Before: 3 calls
_, err = helloFn.Call(ctx, uint64(namePtr), uint64(len(nameBytes)))
ptrResults, _ := getResultPtrFn.Call(ctx)
lenResults, _ := getResultLenFn.Call(ctx)
resultPtr := uint32(ptrResults[0])
resultLen := uint32(lenResults[0])
// After: 1 call
result, err := helloFn.Call(ctx, uint64(namePtr), uint64(len(nameBytes)))
// Extract pointer (upper 32 bits)
resultPtr := uint32(result[0] >> 32)
// Extract length (lower 32 bits)
resultLen := uint32(result[0])
// Read result
resultBytes, ok := mod.Memory().Read(resultPtr, resultLen)
The Decoding Technique
// Right shift by 32 bits to get the pointer
resultPtr := uint32(result[0] >> 32)
// Cast to uint32 to automatically get the lower 32 bits
resultLen := uint32(result[0])
Concrete Example:
result[0] = 0x00001234000000FF
Extract pointer:
result[0] >> 32
= 0x0000000000001234
uint32(...) = 0x00001234
Extract length:
uint32(result[0])
= 0x000000FF (lower 32 bits only)
Complete Flow Diagram with the New Approach

Application to Host Functions
This optimization also applies to host functions! Here's how:
WASM Module (Guest)
// Declaration of the host function that now returns a uint64
//export hostCallStringHandler
func hostCallStringHandler(ptr, length uint32) uint64
//export processWithHost
func processWithHost(ptr, length uint32) uint64 {
input := ptrToString(ptr, length)
message := "Message from WASM: " + input
messageBytes := []byte(message)
messagePtr := uintptr(unsafe.Pointer(&messageBytes[0]))
messageLen := uint32(len(messageBytes))
// Call the host function that returns encoded ptr+len
hostResult := hostCallStringHandler(uint32(messagePtr), messageLen)
// Extract pointer and length from host result
hostPtr := uint32(hostResult >> 32)
hostLen := uint32(hostResult)
// Read and process the result
hostResultStr := ptrToString(hostPtr, hostLen)
resultData := []byte("Result from host: " + hostResultStr)
resultPtr := uintptr(unsafe.Pointer(&resultData[0]))
resultLen := uint32(len(resultData))
// Encode and return
return (uint64(resultPtr) << 32) | uint64(resultLen)
}
Host (Go with Wazero)
// Define the host function that returns I64 (uint64)
_, err := r.NewHostModuleBuilder("env").
NewFunctionBuilder().
WithGoModuleFunction(
api.GoModuleFunc(runner.hostCallStringHandler),
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, // Params: ptr, len
[]api.ValueType{api.ValueTypeI64}, // Return: uint64
).
Export("hostCallStringHandler").
Instantiate(ctx)
// Host function implementation
func (w *WasmRunner) hostCallStringHandler(ctx context.Context, m api.Module, stack []uint64) {
ptr := uint32(stack[0])
length := uint32(stack[1])
data, _ := m.Memory().Read(ptr, length)
input := string(data)
// Process
result := strings.ToUpper(input) + " [PROCESSED BY HOST]"
// Write to WASM memory
resultBytes := []byte(result)
resultPtr := uint32(2048)
m.Memory().Write(resultPtr, resultBytes)
// Encode and return via the stack
stack[0] = (uint64(resultPtr) << 32) | uint64(len(resultBytes))
}
Example with Rust
This technique works with any language compiled to WASM:
// Rust WASM module
#[no_mangle]
pub extern "C" fn hello(ptr: u32, len: u32) -> u64 {
unsafe {
let input = ptr_to_string(ptr, len);
let result = format!("Hello, {}!!!", input);
let (result_ptr, result_len) = string_to_ptr_len(result);
// Encode: same technique as in Go
((result_ptr as u64) << 32) | (result_len as u64)
}
}
Why It Works So Well
1. Reduced FFI Crossings
Each function call between the host and WASM module crosses the FFI barrier (Foreign Function Interface). This is expensive in terms of performance.
Before: 3 FFI crossings
Host → WASM (hello)
WASM → Host (getResultPtr)
WASM → Host (getResultLen)
After: 1 FFI crossing
Host → WASM (hello) → returns everything
2. No Global State
Global variables cause problems:
Not thread-safe
Difficult to test
Can cause subtle bugs
The new approach is stateless.
3. Universal Compatibility
This technique works with:
Go (TinyGo)
Rust
C/C++
AssemblyScript
All WASM languages
Because it only uses basic binary operations. And of cours the performances will be better.
And on the GoloScript side, what does it look like?
Theoretically, no changes (even though the implementation behind it has changed)
With string parameter
module examples.wasm.StringExample
function main = |args| {
# Load the WASM module (TinyGo compiled)
let runner = wasmLoad("./guest/guest.wasm")
println("✓ WASM module loaded successfully")
println("")
# Check if the 'hello' function exists
if wasmHasFunction(runner, "hello") {
println("✓ Function 'hello' found in module")
# Call the hello function with different names
let names = ["🌸 Alice", "🤗 Bob", "🥸 Charlie", "🤓 GoloScript User"]
foreach name in names {
let result = wasmCallString(runner, "hello", name)
println(" → " + result)
}
} else {
println("✗ Function 'hello' not found")
}
println("")
println("Closing WASM runner...")
wasmClose(runner)
}
With host function
module examples.wasm.HostFunction
function main = |args| {
println("🚀 Testing WASM host functions...")
# Load the WASM module
let runner = wasmLoad("./guest/main.wasm")
println("✅ WASM module loaded")
# Create a handler function that will be called from WASM
let myHandler = |input| {
println("🔵 Host (GoloScript) receives: " + input)
# Process the input - convert to uppercase and add a message
let result = input: toUpperCase() + " [PROCESSED BY GOLO HOST]"
println("🟢 Host (GoloScript) returns: " + result)
return result
}
# Register the handler (always uses "default" as the handler name)
wasmRegisterStringHandler(runner, myHandler)
println("✅ Handler registered")
# Call the WASM function that will call our handler
println("\n📞 Calling WASM function 'processWithHost'...")
let result = wasmCallString(runner, "processWithHost", "Hello from GoloScript!")
println("\n✨ Final result: " + result)
# Clean up
wasmClose(runner)
}
And so naturally, I've published a new release of GoloScript:
https://codeberg.org/TypeUnsafe/golo-script/releases/tag/v0.0.0-alpha.5
Have fun with Golo 🤓