How to run a Cypher engine in the browser with WebAssembly

Run Cypher in a browser tab with no database server, by loading Relate on first use, queueing queries, seeding a graph and handling an engine crash.

GraphAcademy runs Cypher in the browser tab for Neo4j Fundamentals, with no database server behind it. It does that with Relate, an open-source embedded graph database that speaks Cypher.

Relate compiles to WebAssembly, so the database runs inside the page itself and there is no server to run. The page serves the engine as static files. It's tested against the openCypher TCK (technology compatibility kit).

1. Install the WebAssembly package

First, install the package from npm:

bash
npm install @relate/relate-wasm

2. Create a database and run a query

Then initialize the module, construct a RelateDb and call execute. It takes a query and a JSON string of parameters (or null), and returns a JSON string:

js
import init, { RelateDb } from "@relate/relate-wasm";
 
await init();
 
const db = new RelateDb();
const result = JSON.parse(db.execute("RETURN 1 AS n", null));
// result.columns: ["n"]
// result.rows: [{ n: 1 }]
// result.error: null

The result also carries columnTypes, stats, plan, notifications and elapsed_ms. Engine errors arrive in error, not as exceptions.

3. Load the engine on first use

The WebAssembly file is large, and most pages never run a query. Load the module with a dynamic import(), which lets the bundler split it, and the .wasm file it references, into its own chunk. Cache the promise at module level so that concurrent callers share one load:

js
let dbPromise = null;
 
function getDb() {
  dbPromise ??= import("@relate/relate-wasm").then(async (mod) => {
    await mod.default();
    return new mod.RelateDb();
  });
  return dbPromise;
}

A page that never calls getDb never requests the chunk.

4. Run queries one at a time

The generated glue code guards its mutable exports with a RefCell. If a second call arrives while the first is in flight, the engine throws "recursive use of an object detected", and UI code that fires queries on clicks and on mount will trigger that.

Chain every call onto a tail promise, so that calls run one after another. The catch keeps one failed call from blocking the ones behind it:

js
let tail = Promise.resolve();
 
function enqueue(task) {
  const run = tail.then(task);
  tail = run.catch(() => {});
  return run;
}
 
export function query(cypher, params = null) {
  return enqueue(async () => {
    const db = await getDb();
    const result = JSON.parse(
      db.execute(cypher, params && JSON.stringify(params))
    );
    if (result.error) throw new Error(result.error);
    return result;
  });
}

Keep one engine for the whole tab by leaving dbPromise at module scope, as above. Our first version built an engine per lesson, so every navigation rebuilt an empty graph and threw away the seed and anything the learner had created.

5. Seed the graph

A server database is seeded by code holding a connection. Here the seed is a list of Cypher statements that you run through the same queue. Because they're queued, any query issued after seed waits for it, so nobody sees a half-loaded graph:

js
export function seed(statements) {
  return enqueue(async () => {
    const db = await getDb();
    try {
      for (const statement of statements) {
        const result = JSON.parse(db.execute(statement, null));
        if (result.error) throw new Error(result.error);
      }
    } catch (err) {
      db.execute("MATCH (n) DETACH DELETE n", null);
      throw err;
    }
  });
}

Write the statements with MERGE so that running them twice doesn't duplicate anything. If a statement fails, the catch empties the graph, so the next attempt starts clean instead of stacking duplicates on a partial load.

Call it once, before the first query:

js
await seed([
  "MERGE (:Person {name: 'Tom Hanks'})",
  "MERGE (:Movie {title: 'Toy Story'})",
  `MATCH (p:Person {name: 'Tom Hanks'}), (m:Movie {title: 'Toy Story'})
   MERGE (p)-[:ACTED_IN]->(m)`,
]);
 
const { rows } = await query(
  "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) RETURN p.name AS person, m.title AS movie"
);
// rows: [{ person: "Tom Hanks", movie: "Toy Story" }]

The seed for Neo4j Fundamentals is a seed.cypher file of 16 MERGE statements, and they load without error when run against the engine directly.

6. Handle an engine crash

On wasm32, a Rust panic aborts the module. JavaScript sees a WebAssembly.RuntimeError, and the instance can't be used afterward. Rebuilding the database quietly and carrying on would discard the learner's data and hide a real engine bug, so mark the engine as crashed and make every later call fail with an error that says what happened.

The package exports isWasmTrap(err) to tell a crash from an ordinary error. It returns true when the instance must be replaced, and false for any error after which the instance is still usable. Replace query with a version that checks for a crash:

js
import { isWasmTrap } from "@relate/relate-wasm/trap.js";
 
let crashed = null;
 
export function query(cypher, params = null) {
  return enqueue(async () => {
    if (crashed) throw crashed;
    const db = await getDb();
    try {
      const result = JSON.parse(
        db.execute(cypher, params && JSON.stringify(params))
      );
      if (result.error) throw new Error(result.error);
      return result;
    } catch (err) {
      if (isWasmTrap(err)) {
        crashed = new Error(
          `The in-browser database crashed and cannot be reused: ${err.message}. ` +
            "Reload the page to continue."
        );
        throw crashed;
      }
      throw err;
    }
  });
}

Next steps

The Relate docs describe two features that this guide doesn't use. executeScript runs a multi-statement script and returns one result per statement. Its scanner knows that a ; inside a string, a backtick identifier or a comment isn't a terminator, and it stops at the first error, which is close to what seed does by hand.

The other is durable storage. await RelateDb.open("opfs://name") opens a database in the browser's origin-private file system, from a dedicated worker only, so a learner's experiments could survive a reload. We run purely in memory and haven't tried it.

The Relate browser guide covers serving the .wasm file as a static file, running the engine in a worker and storing data in the browser.

Try it yourself

Enroll in Neo4j Fundamentals and run a query in the engine described above.

Neo4j Fundamentals

Learn Neo4j from scratch: how graph databases work, nodes and relationships, and your first Cypher queries — a free, hands-on tutorial in your browser.

Comments (0)

Loading comments...