Run in a worker#

Keep your UI responsive while a large project is leveled or evaluated by moving the Project to a worker thread.

Every Project method is synchronous and runs on the thread that calls it. A long level or evaluate call on the main thread blocks rendering and input until it returns. Inside a worker, the same call blocks only the worker.

Prerequisites#

  • A bundler or runtime that loads module workers: a modern browser, or Node 18 or later.
  • Data that's already plain objects. Inputs and results cross the thread boundary with postMessage, so they must be structured-cloneable. ProjectData, change sets and all results are plain objects, arrays, strings, numbers and Dates, so they qualify.

Steps#

  1. Create the worker file. It owns one Project, calls the requested method, and posts back either the result or the error fields:

    // scheduler.worker.js
    import { Project } from "plen-js";
    
    const project = new Project();
    
    self.onmessage = (event) => {
      const { id, method, args } = event.data;
      try {
        const result = project[method](...args);
        self.postMessage({ id, result });
      } catch (e) {
        self.postMessage({
          id,
          error: { name: e.name, message: e.message, kind: e.kind, path: e.path, code: e.code },
        });
      }
    };
    

    Send the error as plain fields. A thrown InputError or ProjectError doesn't keep its class, kind, path or code after postMessage.

    You don't need to call init(); it loads nothing and resolves immediately.

  2. On the main thread, start the worker and wrap each message in a promise:

    const worker = new Worker(new URL("./scheduler.worker.js", import.meta.url), { type: "module" });
    
    let nextId = 0;
    const pending = new Map();
    
    worker.onmessage = (event) => {
      const { id, result, error } = event.data;
      const { resolve, reject } = pending.get(id);
      pending.delete(id);
      if (error) reject(Object.assign(new Error(error.message), error));
      else resolve(result);
    };
    
    function call(method, ...args) {
      const id = nextId++;
      return new Promise((resolve, reject) => {
        pending.set(id, { resolve, reject });
        worker.postMessage({ id, method, args });
      });
    }
    
  3. Call the project methods through call and await the results:

    async function main() {
      const data = {
        tasks: [
          { id: "a", start_date: "2026-08-10 08:00", duration: 5, priority: 100 },
          { id: "b", start_date: "2026-08-12 08:00", duration: 2, priority: 900 },
        ],
        resources: [{ id: "dev" }],
        assignments: [
          { task: "a", resource: "dev", value: 1 },
          { task: "b", resource: "dev", value: 1 },
        ],
      };
    
      await call("solve", data, { includeUnchanged: true });
      const leveled = await call("level", { order: "priority" });
      console.log(leveled.rounds, JSON.stringify(leveled.updates));
    }
    
    main().catch((e) => console.error(e.name, e.code ?? e.path, e.message));
    
  4. Check errors by name instead of instanceof. A rejected call carries the fields the worker sent:

    try {
      await call("preview", changes, { baselineId });
    } catch (e) {
      if (e.name === "ProjectError" && e.code === "stale-baseline") {
        // The plan changed since this preview started; drop the stale result.
      }
    }
    

Keep the calls for one project in a single worker. The worker handles messages one at a time, so the calls run in the order you send them, as they would on the main thread.

evaluate scores candidates one after another, and one worker runs one evaluation at a time.

Node: use worker_threads#

In Node, the worker talks to parentPort instead of self. The method calls and messages stay the same:

// scheduler.worker.js
import { parentPort } from "node:worker_threads";
import { Project } from "plen-js";

const project = new Project();

parentPort.on("message", ({ id, method, args }) => {
  try {
    parentPort.postMessage({ id, result: project[method](...args) });
  } catch (e) {
    parentPort.postMessage({
      id,
      error: { name: e.name, message: e.message, kind: e.kind, path: e.path, code: e.code },
    });
  }
});
// main.js
import { Worker } from "node:worker_threads";

const worker = new Worker(new URL("./scheduler.worker.js", import.meta.url));
worker.on("message", ({ id, result, error }) => {
  // Same handler as in step 2.
});

Result#

level runs in the worker, and the main thread stays free to render and handle input. leveled.updates arrives as the same plain objects a direct call returns:

2 [{"end_date":"2026-08-19 08:00","id":"a","kind":"task","pauses":[{"at":2,"by":"leveling","length":2}]}]

If you create the project with new Project({ dates: "date" }), result dates arrive as Date objects too, because postMessage copies Dates.