Run in a worker#

Keep the page or the server responsive while large solve, level, evaluate or suggestAssignments calls run.

Every raw export is synchronous and blocks the thread that calls it. Load the module inside a worker, keep the JsonProject handle there, and pass JSON strings between the threads. A handle belongs to the module instance that created it, so it can't be sent to another thread. Background: Module and handles.

Prerequisites#

  • The browser build in pkg/, or the Node.js build in pkg-node/ for a worker thread. See Module and handles.
  • For the browser: module workers (new Worker(url, { type: "module" })), since pkg/plen_wasm.js is an ES module.

Steps#

  1. Create the worker script. It loads the module, creates one project and answers each message with the envelope text, unparsed:

    // scheduler-worker.js
    import init, { JsonProject } from "./pkg/plen_wasm.js";
    
    const OPS = new Set([
      "solve", "preview", "analyze", "apply", "level",
      "loadProfile", "evaluate", "slots", "suggestAssignments",
    ]);
    
    // Set the handler right away and wait for the module inside it,
    // so messages sent during loading aren't missed.
    const ready = init().then(() => new JsonProject("{}"));
    
    self.onmessage = async ({ data: { id, op, args } }) => {
      const project = await ready;
      if (op === "close") {
        project.free();
        self.close();
        return;
      }
      try {
        if (!OPS.has(op)) throw new Error(`unknown op ${op}`);
        self.postMessage({ id, text: project[op](...args) });
      } catch (error) {
        self.postMessage({ id, thrown: String(error), trap: error instanceof WebAssembly.RuntimeError });
      }
    };
    

    The OPS list keeps free and clone out of reach of the main thread. init() fetches plen_wasm_bg.wasm from the folder that holds plen_wasm.js. To load it from another URL, see init.

  2. Start the worker on the main thread and turn each request into a promise. A request id matches every reply to its call:

    // scheduler-client.js
    export function startScheduler() {
      const worker = new Worker(new URL("./scheduler-worker.js", import.meta.url), { type: "module" });
      const pending = new Map();
      let nextId = 0;
    
      worker.onmessage = ({ data: { id, text, thrown, trap } }) => {
        const { resolve, reject } = pending.get(id);
        pending.delete(id);
        if (thrown === undefined) resolve(JSON.parse(text));
        else reject(Object.assign(new Error(thrown), { trap }));
      };
    
      const call = (op, ...args) =>
        new Promise((resolve, reject) => {
          const id = nextId++;
          pending.set(id, { resolve, reject });
          worker.postMessage({ id, op, args });
        });
      const close = () => worker.postMessage({ op: "close" });
    
      return { call, close };
    }
    

    Arguments are the same JSON strings the export takes. call resolves with the parsed envelope, so check ok as you would after a direct call. See Error handling. If the main thread only forwards results, resolve with text and skip the parse.

  3. Load the data, then send the heavy calls through call. The main thread stays free while the worker runs them:

    import { startScheduler } from "./scheduler-client.js";
    
    // Two 2-day tasks, both booked full time on dev1 from Monday, August 10, 2026.
    const data = {
      tasks: [
        { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
        { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
      ],
      resources: [{ id: "dev1" }],
      assignments: [
        { task: "A", resource: "dev1", value: 1 },
        { task: "B", resource: "dev1", value: 1 },
      ],
    };
    
    const scheduler = startScheduler();
    
    const loaded = await scheduler.call("solve", JSON.stringify(data), JSON.stringify({ includeUnchanged: true }));
    if (!loaded.ok) throw new Error(loaded.error.message);
    console.log(`loaded ${loaded.result.updates.length} tasks`);
    
    const leveled = await scheduler.call("level", "{}");
    if (!leveled.ok) throw new Error(leveled.error.message);
    for (const update of leveled.result.updates) {
      console.log(`${update.id}: ${update.start_date} .. ${update.end_date}, delay ${update.leveling_delay}`);
    }
    

    Pass null as the data argument for a solve or analyze without data: scheduler.call("solve", null, "{}").

  4. Close the worker when the project is no longer needed. The close message frees the handle before the worker stops:

    scheduler.close();
    

    worker.terminate() also releases everything, since the module instance goes away with the worker. It doesn't wait for a running call to finish.

In a Node.js worker thread#

Use pkg-node/ and node:worker_threads. The Node build is ready as soon as require returns, so the worker needs no init():

// scheduler-worker.js
const { parentPort } = require("node:worker_threads");
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const OPS = new Set([
  "solve", "preview", "analyze", "apply", "level",
  "loadProfile", "evaluate", "slots", "suggestAssignments",
]);
const project = new JsonProject("{}");

parentPort.on("message", ({ id, op, args }) => {
  if (op === "close") {
    project.free();
    parentPort.close();
    return;
  }
  try {
    if (!OPS.has(op)) throw new Error(`unknown op ${op}`);
    parentPort.postMessage({ id, text: project[op](...args) });
  } catch (error) {
    parentPort.postMessage({ id, thrown: String(error), trap: error instanceof WebAssembly.RuntimeError });
  }
});

On the main thread, the client from step 2 changes in two lines:

const path = require("node:path");
const { Worker } = require("node:worker_threads");

const worker = new Worker(path.join(__dirname, "scheduler-worker.js"));
worker.on("message", ({ id, text, thrown, trap }) => {
  // same body as worker.onmessage in step 2
});

Several workers from one download#

A module instance is single-threaded, so one worker runs one call at a time. For parallel work, start several workers. Each one holds its own instance and its own projects. To fetch and compile the .wasm file only once, compile it on the main thread and send the compiled module to each worker:

// main thread
const wasmUrl = new URL("./pkg/plen_wasm_bg.wasm", import.meta.url);
const wasmModule = await WebAssembly.compileStreaming(fetch(wasmUrl));

const workers = [1, 2, 3].map(() => {
  const worker = new Worker(new URL("./pool-worker.js", import.meta.url), { type: "module" });
  worker.postMessage({ wasmModule });
  return worker;
});
// pool-worker.js
import { initSync, JsonProject } from "./pkg/plen_wasm.js";

let project;

self.onmessage = ({ data }) => {
  if (data.wasmModule) {
    initSync({ module: data.wasmModule });
    project = new JsonProject("{}");
    return;
  }
  // ...handle { id, op, args } as in step 1
};

Messages to one worker arrive in order, so the module message is handled before any request.

evaluate doesn't split its candidates across workers by itself. To score them in parallel, give each worker a share of the candidates and its own solved copy of the project.

If the worker hits an engine failure#

An internal engine failure doesn't come back as an error envelope. The call throws instead, and the client above rejects with trap: true.

Note

An internal engine failure surfaces as a WebAssembly.RuntimeError; after one, create the project again (or reload the module) before continuing.

In a worker, the cleanest way to reload is to terminate() it, start a new one, and send the full data again with solve.

Result#

Open the page with the browser console. It prints:

loaded 2 tasks
B: 2026-08-12 08:00 .. 2026-08-14 08:00, delay 2

The page stays interactive while the worker runs. The handle lives only in the worker, and the main thread sees nothing but JSON.