WASM Handlers
Tick covers 80% of use cases. For the rest -- arithmetic, string manipulation, complex business rules -- you can escape to WebAssembly for full programmatic control.
When to use WASM
Use Tick when you can. Declarative handlers are simpler to reason about, easier to audit, and have no build step.
Escape to WASM when you need:
- Arithmetic calculations (
price * quantity * (1 - discount)) - String manipulation (formatting, parsing, concatenation)
- Date/time logic (add days, calculate durations)
- Complex business rules (multi-condition logic trees)
- Loops with complex termination conditions
- Data transformations Tick can't express
What WASM can and cannot do
Can do: - Any pure computation (math, string ops, data transforms) - Read event data and current aggregate state - Return new state (handlers) or emit events (implications) - Complex conditional logic
Cannot do: - Network calls or I/O - Access filesystem - Call external services - Persist data outside the aggregate
WASM handlers are pure functions. No side effects, no external access.
The contracts
WASM is used in two places: handlers (computing aggregate state) and implications (reactive event creation). Each has a different contract.
Handler contract
Handlers receive the current aggregate state and the event, and return the new state.
Input:
json
{
"state": { "items": [], "total": 0 },
"event": {
"key": "order:abc123",
"type": "had_item_added",
"data": { "sku": "WIDGET-1", "price": 9.99, "quantity": 2 },
"metadata": { "actor": { "type": "user", "id": "u001" } }
}
}
Output (success):
json
{
"state": {
"items": [{ "sku": "WIDGET-1", "price": 9.99, "quantity": 2, "line_total": 19.98 }],
"total": 19.98
}
}
The returned state replaces the aggregate's state entirely. This is the key difference from implications -- handlers transform state, implications emit new events.
Output (error):
json
{
"error": {
"type": "validation_failed",
"message": "Quantity must be positive",
"code": "INVALID_QUANTITY",
"context": { "field": "quantity", "value": -5 }
}
}
Error fields:
- type (required) -- Error category like "validation_failed", "business_rule", "precondition"
- message (required) -- Human-readable description
- code (optional) -- Application-specific code for programmatic handling
- context (optional) -- Structured data about the error
Handler errors are returned to the client with a 422 status.
Implication contract
Implications receive the triggering event and context, and return actions (events to emit).
Input:
json
{
"event": {
"key": "order:abc123",
"type": "was_placed",
"data": { "items": [{ "product_id": "p-123", "quantity": 2 }] },
"metadata": { "actor": { "type": "user", "id": "u001" } }
},
"context": {}
}
Output:
json
{
"actions": [
{
"emit": {
"aggregate_type": "inventory",
"id": "p-123",
"event_type": "was_reserved",
"data": { "quantity": 2, "order_id": "abc123" }
}
}
]
}
Each action's emit object requires:
- key (as "type:id") or both aggregate_type and id
- event_type -- The event type to create
- data -- Event payload (optional, defaults to {})
Implications can also return errors using the same error format as handlers.
Spec configuration
Handler
Short form -- blob name only, uses default entrypoint apply_handler:
{
"aggregate_types": {
"order": {
"events": {
"had_item_added": {
"schema": { "..." : "..." },
"handler": { "wasm": "order-handler.wasm" }
}
}
}
}
}
Long form -- custom entrypoint and timeout:
{
"handler": {
"wasm": {
"blob_name": "order-handler.wasm",
"entrypoint": "handle_item_added",
"timeout_ms": 100
}
}
}
Handler config fields:
- blob_name (required) -- Name of the uploaded WASM blob
- entrypoint (optional) -- Function to call, defaults to "apply_handler"
- timeout_ms (optional) -- Execution timeout, defaults to 100
Implication
Short form:
{
"implications": [
{ "wasm": "order-notifier.wasm" }
]
}
Long form:
{
"implications": [
{
"wasm": {
"blob_name": "order-enricher.wasm",
"entrypoint": "compute_notifications",
"mode": "emit"
}
}
]
}
Implication config fields:
- blob_name (required) -- Name of the uploaded WASM blob
- entrypoint (optional) -- Function to call, defaults to "compute_implications"
- mode (optional) -- "emit" (return events) or "transform" (enrich context for pipeline chaining), defaults to "emit"
WASM implications can be used in pipelines alongside tick implications:
{
"implications": [
{
"pipeline": [
{ "wasm": { "blob_name": "enrich.wasm", "mode": "transform" } },
{
"emit": {
"aggregate_type": "notification",
"id": "$.metadata.actor",
"event_type": "was_queued",
"data": {}
}
}
]
}
]
}
Memory interface
j17 uses a simple memory protocol for passing data between the host and your WASM module. Your module must export three functions:
| Export | Signature | Purpose |
|---|---|---|
malloc |
(size: i32) -> i32 |
Allocate a buffer, return pointer |
free |
(ptr: i32) |
Free a buffer (optional, not called by j17) |
apply_handler |
(ptr: i32, len: i32) -> i64 |
Process input, return packed result |
The entrypoint (default apply_handler) returns a packed i64: (output_ptr << 32) | output_len.
Data flow:
1. j17 calls malloc(input_size) to allocate a buffer in WASM memory
2. j17 copies the input JSON into that buffer
3. j17 calls apply_handler(ptr, len) with the buffer location
4. Your function reads the input, computes the result, allocates an output buffer, and returns the packed pointer+length
5. j17 reads the output JSON from WASM memory
Building WASM
Any language that compiles to WASM works. The module must export malloc and apply_handler (or your custom entrypoint name).
AssemblyScript
TypeScript-like syntax, compiles directly to WASM with no separate toolchain.
import { JSON } from "assemblyscript-json";
export function malloc(size: i32): i32 {
return heap.alloc(size) as i32;
}
export function free(ptr: i32): void {
heap.free(ptr);
}
export function apply_handler(ptr: i32, len: i32): i64 {
// Read input JSON from memory
const input = String.UTF8.decodeUnsafe(ptr, len);
const json = <JSON.Obj>JSON.parse(input);
const event = json.getObj("event")!;
const state = json.getObj("state")!;
// Your logic here...
const output = '{"state": {"processed": true}}';
// Write output to memory and return packed pointer+length
const outBytes = String.UTF8.encode(output);
const outPtr = changetype<i32>(outBytes);
const outLen = outBytes.byteLength;
return (i64(outPtr) << 32) | i64(outLen);
}
Build:
asc handler.ts -o handler.wasm --optimize
Zig
const std = @import("std");
var allocator = std.heap.wasm_allocator;
export fn malloc(size: i32) i32 {
const slice = allocator.alloc(u8, @intCast(size)) catch return 0;
return @intCast(@intFromPtr(slice.ptr));
}
export fn free(ptr: i32) void {
_ = ptr;
// wasm_allocator doesn't support individual frees
}
export fn apply_handler(ptr: i32, len: i32) i64 {
const input = @as([*]u8, @ptrFromInt(@intCast(ptr)))[0..@intCast(len)];
// Parse input JSON, compute result...
const parsed = std.json.parseFromSlice(
std.json.Value, allocator, input, .{}
) catch return 0;
defer parsed.deinit();
// Build output
const output = "{\"state\": {\"computed\": true}}";
const out_buf = allocator.alloc(u8, output.len) catch return 0;
@memcpy(out_buf, output);
const out_ptr: i64 = @intCast(@intFromPtr(out_buf.ptr));
const out_len: i64 = @intCast(out_buf.len);
return (out_ptr << 32) | out_len;
}
Build:
zig build-lib handler.zig -target wasm32-freestanding -O ReleaseSmall
Rust
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Deserialize)]
struct Input {
event: Value,
state: Value,
}
#[derive(Serialize)]
struct Output {
state: Value,
}
#[no_mangle]
pub extern "C" fn malloc(size: i32) -> *mut u8 {
let mut buf = Vec::with_capacity(size as usize);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf);
ptr
}
#[no_mangle]
pub extern "C" fn free(ptr: *mut u8, size: i32) {
unsafe {
let _ = Vec::from_raw_parts(ptr, 0, size as usize);
}
}
#[no_mangle]
pub extern "C" fn apply_handler(ptr: *const u8, len: i32) -> i64 {
let input_bytes = unsafe {
std::slice::from_raw_parts(ptr, len as usize)
};
let input: Input = serde_json::from_slice(input_bytes).unwrap();
// Your logic here...
let output = Output {
state: json!({"processed": true}),
};
let output_bytes = serde_json::to_vec(&output).unwrap();
let out_ptr = output_bytes.as_ptr() as i64;
let out_len = output_bytes.len() as i64;
std::mem::forget(output_bytes);
(out_ptr << 32) | out_len
}
Build:
cargo build --target wasm32-unknown-unknown --release
C
#include <stdlib.h>
#include <string.h>
__attribute__((export_name("malloc")))
void* wasm_malloc(int size) {
return malloc(size);
}
__attribute__((export_name("free")))
void wasm_free(void* ptr) {
free(ptr);
}
__attribute__((export_name("apply_handler")))
long long apply_handler(char* ptr, int len) {
// Parse input JSON (use a JSON library like cJSON)
// ...
// Build output
const char* output = "{\"state\": {\"done\": true}}";
int out_len = strlen(output);
char* out_buf = malloc(out_len);
memcpy(out_buf, output, out_len);
long long result = ((long long)(size_t)out_buf << 32)
| (long long)out_len;
return result;
}
Build:
clang --target=wasm32 -O2 -nostdlib \
-Wl,--no-entry -Wl,--export-all \
-o handler.wasm handler.c
Uploading WASM blobs
Upload your compiled .wasm file via the admin API:
curl -X POST https://myapp.j17.dev/_admin/blobs \
-H "Authorization: Bearer $OPERATOR_JWT" \
-F "name=order-handler.wasm" \
-F "file=@./dist/order-handler.wasm"
Blobs are versioned. The spec references by name; j17 uses the latest version. You can pin a specific version with "blob_name": "order-handler.wasm:v2".
Output format
When using WASM as a handler, the output is a complete state replacement:
{ "state": { "status": "processed", "total": 42.50 } }
When using WASM as an implication, the output contains actions:
{
"actions": [
{ "emit": { "key": "user:u001", "event_type": "was_notified", "data": { "message": "Order placed" } } },
{ "emit": { "aggregate_type": "inventory", "id": "p-123", "event_type": "was_reserved", "data": { "quantity": 2 } } }
]
}
Error handling
Both handlers and implications can return structured errors:
{
"error": {
"type": "business_rule",
"message": "Cannot add items to a completed order",
"code": "ORDER_COMPLETED",
"context": { "order_status": "completed" }
}
}
j17 distinguishes between: - Handler errors -- returned to the caller as a 422 response - Runtime errors -- WASM traps, timeouts, memory violations -- returned as 500 responses with diagnostic hints
Testing WASM locally
Test your handler before uploading:
# Run your WASM with test input using any WASM runtime
echo '{"state": {}, "event": {"key": "order:123", "type": "had_item_added", "data": {"sku": "W1", "price": 9.99}}}' \
| your-wasm-runner handler.wasm
# Or upload to a test environment and exercise via the API
curl -X POST https://myapp-test.j17.dev/order/test-123/had_item_added \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"data": {"sku": "W1", "price": 9.99, "quantity": 1}}'
For unit testing in your language's native test framework, extract your business logic into testable functions and test the JSON-in/JSON-out contract independently of the WASM memory interface.
Performance
j17 executes WASM via the wasm3 interpreter (not a JIT). This is a deliberate choice for security and predictability.
Performance characteristics: - Cold start: Microseconds (module parse + instantiate) - Warm execution: Sub-millisecond for typical handlers - Default timeout: 100ms per invocation - Stack size: 64KB default
Compared to Tick handlers (~0.01ms), WASM has measurable overhead. The difference adds up at scale, so use WASM only when Tick can't express your logic.
Tips for fast WASM handlers: - Keep modules small (<1MB recommended) - Avoid heavy allocations in hot paths - Reuse buffers where possible - Pre-compute what you can at build time
Debugging
If your WASM handler fails, check these common issues in order:
Missing exports -- Are
mallocand your entrypoint (defaultapply_handler) exported? Usewasm-objdump -x handler.wasmorwasm-tools print handler.wasmto inspect exports.Wrong return value -- The entrypoint must return an i64 with
(ptr << 32) | len. A common mistake is returning just a pointer or just a length.Invalid JSON -- Is your output valid JSON matching the contract? Missing
"state"key (for handlers) or"actions"key (for implications) will produce aninvalid_outputerror.Panics -- Unhandled errors in Rust/Zig abort the WASM instance, surfacing as a trap. Handle all errors gracefully and return an error JSON instead.
Memory bounds -- Writing past the end of allocated memory causes a trap. Ensure your output buffer is large enough.
Enable debug tracing to see WASM input/output in the j17 logs:
curl -X POST https://myapp-test.j17.dev/_admin/debug \
-H "Authorization: Bearer $JWT" \
-d '{"wasm_trace": true}'
Security
WASM handlers run with defense-in-depth isolation:
| Layer | Protection |
|---|---|
| WASM | Memory sandboxing, no system calls |
| wasm3 | Resource limits, interpreter-based (no JIT = no JIT bugs) |
| Zig NIF | Crash isolation via BEAM NIF semantics |
| BEAM | Process isolation, fault tolerance |
Constraints enforced at runtime: - 64KB stack (configurable) - 100ms execution timeout (configurable per handler) - No filesystem access - No network access - Memory isolated per invocation - No access to other instances' data
Exceeding limits returns a 500 with a diagnostic error (timeout, OOM, or trap).
Example: Order line total calculation
A handler that computes line totals -- something Tick can't do because it requires multiplication:
// AssemblyScript
import { JSON } from "assemblyscript-json";
export function malloc(size: i32): i32 {
return heap.alloc(size) as i32;
}
export function free(ptr: i32): void {
heap.free(ptr);
}
export function apply_handler(ptr: i32, len: i32): i64 {
const input = String.UTF8.decodeUnsafe(ptr, len);
const json = <JSON.Obj>JSON.parse(input);
const event = json.getObj("event")!;
const data = event.getObj("data")!;
const state = json.getObj("state")!;
const price = data.getNum("price")!.valueOf();
const quantity = data.getInteger("quantity")!.valueOf();
const line_total = price * f64(quantity);
// Get current total from state, add line total
const current_total = state.getNum("total")
? state.getNum("total")!.valueOf()
: 0.0;
const output = `{"state": {"total": ${current_total + line_total}}}`;
const outBytes = String.UTF8.encode(output);
const outPtr = changetype<i32>(outBytes);
const outLen = outBytes.byteLength;
return (i64(outPtr) << 32) | i64(outLen);
}
Example: Fraud detection implication
An implication that checks order amount and emits a review event for high-value orders:
export function apply_handler(ptr: i32, len: i32): i64 {
const input = String.UTF8.decodeUnsafe(ptr, len);
const json = <JSON.Obj>JSON.parse(input);
const event = json.getObj("event")!;
const data = event.getObj("data")!;
const metadata = event.getObj("metadata")!;
const actor = metadata.getObj("actor")!;
const amount = data.getNum("amount")!.valueOf();
const user_id = actor.getString("id")!.valueOf();
let output: string;
if (amount > 1000.0) {
output = `{
"actions": [{
"emit": {
"aggregate_type": "review",
"id": "${user_id}",
"event_type": "was_flagged",
"data": {"amount": ${amount}, "reason": "high_value_order"}
}
}]
}`;
} else {
output = '{"actions": []}';
}
const outBytes = String.UTF8.encode(output);
const outPtr = changetype<i32>(outBytes);
const outLen = outBytes.byteLength;
return (i64(outPtr) << 32) | i64(outLen);
}
See also
- Tick reference -- Declarative handlers (use these first)
- Implications reference -- Reactive event creation
- Spec reference -- Using WASM in specs