In a recent DZone article, Running Sentiment Analysis Inside Neo4j With a Java Plugin, we explored several approaches to running sentiment analysis inside the Neo4j database engine. One of those approaches — embedding a Wasm runtime inside a Java UDF — was described like this:
Theoretically, we could embed a Wasm runtime such as wasmtime inside a Java UDF and execute the VADER Wasm module from within Neo4j, getting Wasm's sandbox guarantees inside Neo4j's plugin model. It's technically feasible but no published working example appears to exist and the complexity cost is high relative to the alternatives. An interesting idea to watch, but not practical today.
This article builds that working example.
We'll show how to embed a real VADER sentiment analyzer compiled to Wasm inside a Neo4j Java UDF, returning a full polarity score map callable directly from Cypher. We'll cover the tools and inspection techniques needed to understand what the Wasm compiler generates and why the Java calling convention looks the way it does.
We're embedding a wasmtime Wasm runtime inside a Neo4j Java UDF using wasmtime-java, a community JNI binding for the Wasmtime runtime. It's not an official Bytecode Alliance product, but it ships prebuilt native libraries for all major platforms and is sufficient for this proof-of-concept. A Rust function compiled to WebAssembly rides inside the plugin JAR alongside the Java code. When Cypher calls the UDF, Java initializes the Wasm runtime, loads the binary, and invokes the Rust function — all inside the Neo4j JVM process with no external API calls and no network round-trips.
Note: This article was tested specifically against wasmtime-java 0.19.0. The API used here is version-specific; newer releases or alternative JVM Wasm runtimes may expose different interfaces and calling conventions.
Prerequisites
You'll need the following installed if you wish to follow along. We're using Apple Silicon (ARM64) as our development platform, so we'll note where the setup differs from other platforms.
Java
We're using OpenJDK 21 (tested with 21.0.12.1). Install it using your platform's package manager or download it directly from adoptium.net.
On macOS via Homebrew:
Shell
brew install openjdk@21
On Ubuntu/Debian:
Shell
sudo apt install openjdk-21-jdk
On Windows, download and run the installer from Adoptium.
Confirm your Java version:
Shell
java -version
You should see a Java 21 runtime. If you're on Apple Silicon, also confirm you're running a native ARM64 JVM with:
Shell
uname -m
You should see arm64. Not running under ARM64 will likely break the wasmtime-java JNI library loading.
Maven
We're using Maven3.9.6. On Apple Silicon, be cautious about installing Maven via Homebrew as, at the time of writing, the Homebrew Maven formula pulls in OpenJDK 26 as a dependency, which conflicts with a Java 21 installation. If your package manager installs an incompatible JDK alongside Maven, verify the runtime with mvn -version and configure JAVA_HOME as necessary. Installing Maven manually is the safest approach:
Shell
cd ~
curl -O https://archive.apache.org/dist/maven/maven-3/3.9.6/binaries/apache-maven-3.9.6-bin.tar.gz
tar xzf apache-maven-3.9.6-bin.tar.gz
Then add Maven to your PATH and make it persist across terminal sessions:
This is the interface types generator for WebAssembly. There are two distinct version numbers to be aware of: the wit-bindgen-cli command-line tool and the wit-bindgen Rust crate used as a dependency inside the Wasm module. These can differ. We tested with CLI version 0.59.0 and Rust crate version 0.40.0 (specified in Cargo.toml). The generated binary identifies the crate version through the export name cabi_realloc_wit_bindgen_0_40_0. Install the pinned CLI version via Cargo on all platforms:
Shell
cargo install wit-bindgen-cli --version 0.59.0
Confirm it's installed:
Shell
wit-bindgen --version
Neo4j Desktop
We're using Neo4j Desktop with a local database instance. Download from Neo4j for Desktop.
The pom.xml in this article is pinned to Neo4j 2026.07.0 — update the neo4j.version property to match your own Desktop installation.
wasmtime-java Platform Support
The wasmtime-java library ships prebuilt JNI native libraries for:
macOS aarch64
macOS x86_64
Linux aarch64
Linux x86_64
Windows x86_64
No additional setup is needed, as Maven pulls the correct native library for your platform automatically.
Version Summary
For reference, here are all the component versions used in this article:
Component
Version
OpenJDK
21.0.12.1
Maven
3.9.6
Rust
1.96.0
WABT
1.0.41
wit-bindgen CLI
0.59.0
wit-bindgen crate
0.40.0
vader_sentiment crate
0.1.1
wasmtime-java
0.19.0
Neo4j
2026.07.0
Getting the Code
Clone the repository before following along. All source files are provided so you don't need to create them manually.
Shell
cd ~
git clone --filter=blob:none --sparse https://github.com/VeryFatBoy/neo4j.git
cd neo4j
git sparse-checkout set wasm-udf
mv wasm-udf ../wasm-udf
cd ../wasm-udf
Project Structure
Before creating any files, here's the final layout we're building toward. There are two separate projects:
The Wasm binaries in resources/ are bundled into the plugin JAR at build time. The Rust crate and Maven project are kept separate, and the Wasm binary is the handoff point between them.
The project layout is also shown in Figure 1.
Figure 1. Two-Project Layout
How the Wasm Plumbing Works
Before diving into the code, it's worth understanding the three layers that make this possible.
Core Wasm and WASI
WebAssembly defines a portable binary format and a stack-based execution model. On its own, it only understands numbers, such as integers and floats. When a Wasm module needs system capabilities, like memory allocation or I/O, it uses WASI (WebAssembly System Interface), a standardized set of system calls that a host runtime implements. Our Rust code targets wasm32-wasip1, which means it compiles to Wasm with WASI preview 1 system calls. The wasmtime runtime implements those calls on the host side.
wasmtime-java
This library wraps the wasmtime Wasm runtime in a JNI binding, making it callable from Java. It ships prebuilt native libraries for all major platforms, so adding it as a Maven dependency is all that's needed — no separate wasmtime installation required. The Java API lets us load a Wasm binary, set up a WASI context, and call exported functions directly.
wit-bindgen and the String ABI
Core WebAssembly functions operate on Wasm value types such as integers and floats. WIT (WebAssembly Interface Types) and the Component Model provide higher-level interface types such as strings, tuples, and records; wit-bindgen generates the lowering and lifting code needed to represent those types at the Wasm boundary. For strings, it uses a pointer-and-length convention: the caller allocates memory inside the Wasm module using a generated cabi_realloc function, writes the string bytes there and passes the memory address and byte length as two integers. The Rust code reads the string from that address. For return values, the lowering strategy depends on the type, which we'll see when we inspect the generated binary.
With those three pieces in place, the calling chain looks like this:
Plain Text
Cypher query
-> Neo4j routes to @UserFunction
-> Java initializes wasmtime engine + WASI context
-> Java allocates string in Wasm memory
-> Java calls exported Wasm function
-> Rust executes VADER scoring
-> Java reads result from Wasm memory
-> Java returns Map to Neo4j
-> Neo4j returns result to Cypher
Graphically, the calling chain is also shown in Figure 2.
Figure 2. Calling Chain
We built up to this through two simpler stepping-stone cases:
Case 1: A trivial integer addition to prove the chain works.
Case 2: A single compound score to introduce string passing and WASI.
The full walkthrough of both, including the WasmUDF.java implementation is in a technical report on the GitHub repo.
org.neo4j:neo4j is declared as provided scope — Neo4j is already present in the database JVM at runtime, so we exclude it from the bundled JAR.
We use maven-shade-plugin rather than maven-jar-plugin to produce a fat JAR that bundles wasmtime-java and its native libraries alongside our code.
Update the neo4j.version property to match your own Neo4j Desktop installation.
Case 3: Full Polarity Map
VADER produces four scores: compound, positive, negative, and neutral. In this case, we update the Rust function to return all four and the Java UDF to return them as a Map — matching the return shape of the Java VADER UDF from the previous article.
The sentimentable.wit File
We change the return type from a single f32 to a tuple of four f32 values:
We use a tuple rather than a named record. Both would work, but a tuple is simpler on the Java side — we read four consecutive f32 values from memory at known offsets without needing to decode field names.
The lib.rs File
Rust
wit_bindgen::generate!({
world: "sentimentable",
});
struct Component;
impl Guest for Component {
fn sentimentable(input: String) -> (f32, f32, f32, f32) {
lazy_static::lazy_static! {
static ref ANALYZER: vader_sentiment::SentimentIntensityAnalyzer<'static> =
vader_sentiment::SentimentIntensityAnalyzer::new();
}
let scores = ANALYZER.polarity_scores(input.as_str());
(
*scores.get("compound").unwrap_or(&0.0) as f32,
*scores.get("pos").unwrap_or(&0.0) as f32,
*scores.get("neg").unwrap_or(&0.0) as f32,
*scores.get("neu").unwrap_or(&0.0) as f32,
)
}
}
export!(Component);
Build:
Shell
cd ~/wasm-udf/sentimentable
cargo build --target wasm32-wasip1 --release
Inspecting the Binary
Let's check the exports first:
Shell
wasm-objdump -x target/wasm32-wasip1/release/sentimentable.wasm | grep "^Export" -A 6
Four exports should be present: memory, sentimentable, cabi_realloc, and cabi_realloc_wit_bindgen_0_40_0.
Now let's find the type signature of the sentimentable function. Find the sig index:
Shell
wasm-objdump -x target/wasm32-wasip1/release/sentimentable.wasm | grep "func\[9\]" | head -1
The signature is (i32, i32) -> i32 . This is the key difference between returning a single scalar and returning a tuple: wit-bindgen uses a direct f32 return for a single value, but switches to an indirect result pointer when returning a tuple. What's written at that pointer is four f32 values (16 bytes) at consecutive 4-byte offsets. The Java side reads all four.
This illustrates an important distinction between the WIT interface definition and the generated core Wasm ABI. The WIT signature and the Wasm-level signature are different layers: wit-bindgen lowers WIT types to a core Wasm ABI, and the lowering strategy depends on the return type. A single scalar such as f32 is returned directly as a Wasm value. A tuple is returned indirectly through linear memory, with the caller receiving a pointer to where the values were written. The Java calling code must match the generated ABI rather than the WIT definition, which is why inspecting the binary with wasm-objdump before writing the Java wrapper is essential.
Figure 3 shows the memory layout.
Figure 3. Memory Layout.
Figure 4 compares Cases 2 and 3.
Figure 4. Case 2 vs. Case 3 ABI Comparison
The SentimentUDF.java file
Java
package com.example;
import io.github.kawamuray.wasmtime.Engine;
import io.github.kawamuray.wasmtime.Func;
import io.github.kawamuray.wasmtime.Linker;
import io.github.kawamuray.wasmtime.Memory;
import io.github.kawamuray.wasmtime.Module;
import io.github.kawamuray.wasmtime.Store;
import io.github.kawamuray.wasmtime.WasmFunctions;
import io.github.kawamuray.wasmtime.WasmValType;
import io.github.kawamuray.wasmtime.wasi.WasiCtx;
import io.github.kawamuray.wasmtime.wasi.WasiCtxBuilder;
import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.UserFunction;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
public class SentimentUDF {
@UserFunction("com.example.wasm.sentiment")
@Description("Scores text using VADER sentiment analysis compiled to Wasm. Returns compound, positive, negative, neutral.")
public Map sentiment(@Name("text") String text) throws Exception {
if (text == null || text.isBlank()) {
return Map.of("compound", 0.0, "positive", 0.0, "negative", 0.0, "neutral", 1.0);
}
byte[] wasmBytes;
try (InputStream is = SentimentUDF.class.getResourceAsStream("/sentimentable.wasm")) {
if (is == null) throw new RuntimeException("sentimentable.wasm not found in resources");
wasmBytes = is.readAllBytes();
}
WasiCtx wasi = new WasiCtxBuilder().inheritStdout().inheritStderr().build();
try (Store store = Store.withoutData(wasi);
Engine engine = store.engine();
Module module = Module.fromBinary(engine, wasmBytes);
Linker linker = new Linker(engine)) {
WasiCtx.addToLinker(linker);
linker.module(store, "", module);
Memory memory = linker.get(store, "", "memory").get().memory();
Func reallocFn = linker.get(store, "", "cabi_realloc").get().func();
WasmFunctions.Function4 realloc =
WasmFunctions.func(store, reallocFn,
WasmValType.I32, WasmValType.I32, WasmValType.I32, WasmValType.I32,
WasmValType.I32);
byte[] inputBytes = text.getBytes(StandardCharsets.UTF_8);
int len = inputBytes.length;
int strPtr = realloc.call(0, 0, 1, len);
ByteBuffer buf = memory.buffer(store);
buf.position(strPtr);
buf.put(inputBytes);
Func sentimentFn = linker.get(store, "", "sentimentable").get().func();
WasmFunctions.Function2 scoreFn =
WasmFunctions.func(store, sentimentFn,
WasmValType.I32, WasmValType.I32,
WasmValType.I32);
int resultPtr = scoreFn.call(strPtr, len);
// read four f32 values at 4-byte offsets: compound, pos, neg, neu
ByteBuffer resultBuf = memory.buffer(store);
resultBuf.order(ByteOrder.LITTLE_ENDIAN);
float compound = resultBuf.getFloat(resultPtr);
float positive = resultBuf.getFloat(resultPtr + 4);
float negative = resultBuf.getFloat(resultPtr + 8);
float neutral = resultBuf.getFloat(resultPtr + 12);
Map result = new HashMap<>();
result.put("compound", (double) compound);
result.put("positive", (double) positive);
result.put("negative", (double) negative);
result.put("neutral", (double) neutral);
return result;
}
}
}
The return type is (Map ), the null guard returning a neutral map and the four getFloat() reads at consecutive 4-byte offsets from the result pointer.
We set out to build the working example that our previous article said didn't exist. Here's what we showed.
We embedded a real VADER sentiment analyzer compiled to Wasm inside a Neo4j Java UDF, returning a full polarity map — compound, positive, negative and neutral — matching the return shape of the Java VADER UDF from the previous article. The wit-bindgen tuple ABI writes four f32 values to consecutive memory addresses; the Java side reads them back with a LITTLE_ENDIANByteBuffer. All four scores are correct, capitalization sensitivity works, and the empty string guard returns a sensible neutral map.
The result is a workable integration pattern rather than a universal replacement for a native Java implementation. With the per-call initialization used in this proof of concept, the approach is best suited to low-frequency workloads where Wasm isolation and portability justify the additional complexity. For high-throughput workloads, the natural next step is to benchmark and reuse the Wasmtime engine and compiled module while keeping execution state appropriately isolated between calls.
In the next article, we'll look at running TypeSafe AI's Jev inside Neo4j for calibrated sentiment decisions. Stay tuned!
Comments