[Canonical page](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/scheduling-a-project/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Scheduling a Project

This guide covers how to run the scheduler through the raw WebAssembly exports. It explains when to call the one-shot [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) function and when to keep a [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) handle. It also covers what the solve options change, how forward and backward planning place tasks, and what `converged` means in a result.

## How scheduling works

There are two entry points, and they run the same planner:

- [`schedule(data_json, options_json)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) takes a [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/project-data.md>) snapshot, schedules it once and returns a [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md>). It builds a default project with strict validation, solves it and drops it. There's no handle to free.
- [`JsonProject#solve(data_json, options_json)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) does the same work on a long-lived handle. The project keeps the scheduled dates as its **accepted baseline**, so later calls can edit, preview, level and evaluate against it.

Both take JSON text and return JSON text: an envelope, `{"ok": true, "result": …}` or `{"ok": false, "error": …}`. The envelope and the field spelling are on [Wire format](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md>).

Every solve follows the same steps. The engine parses the JSON, imports the snapshot into its own normalized model and validates it. It builds the scheduling graph from links and summaries, checks for dependency cycles and resolves the scope (the whole project, or one branch with `subtree`). Then it runs the planner until the dates stop changing. The result lists only what moved.

A few rules shape what you get back:

- **Updates are sparse.** `result.updates` holds one entry per task whose dates changed, with only the changed fields set. The entries use the task-data names (`start_date`, `end_date`, `duration`), so you can write them straight back into your own model. To get every task's dates, set `includeUnchanged`.
- **Problems are data, not errors.** Conflicts, cycles, dropped input and non-convergence come back inside an `ok: true` result. An error envelope means invalid input (`kind: "parse"` or `"validation"`) or a call that doesn't fit the project's state (`kind: "project"`). See [Error handling](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md>).
- **Some tasks never move.** Tasks with `auto_scheduling: false`, unscheduled tasks and completed tasks (`use_progress` with `progress == 1`) stay where they are. That's how you lock work in place.
- **Calls are synchronous and CPU-only.** There's no I/O and no clock. A large solve blocks the calling thread, so run it in a Web Worker or a worker thread. See [Run in a worker](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/run-in-a-worker.md>).

## One-shot scheduling

Use [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) in batch jobs, request handlers and edge functions that schedule a snapshot and throw it away.

```js
const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 3 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) {
  const { kind, message, path } = response.error;
  throw new Error(`${kind}: ${message} (at ${path})`);
}
for (const update of response.result.updates) {
  console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
}
```

Task 2 has to wait for task 1, so it's the only update: `task 2: 2026-08-12 08:00 .. 2026-08-17 08:00`. Task 1 is already where it belongs and isn't reported. The default `"global"` calendar works Mon-Fri, 08:00-12:00 and 13:00-17:00, which is why the dates skip the weekend.

`"{}"` means default [`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/solve-options.md>). `schedule` never throws for bad input. It returns an error envelope with kind `parse` or `validation` when the snapshot is rejected.

:::note
`tasks` is required in [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/project-data.md>); send `"tasks": []` for an empty project.
:::

A server that already holds the request body as JSON text can pass it to `schedule` and return the envelope text without parsing either. See [Forward JSON from a server](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/forward-json-from-a-server.md>).

## Solving a project

Keep a [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) when the plan lives on: an editor backend with one project per open document, or a service that applies edits over time.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 3 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const project = new JsonProject("{}");
try {
  // Install the data, schedule it and accept the dates as the baseline.
  const loaded = unwrap(project.solve(JSON.stringify(data), "{}"));

  // Nothing changed since: the cached result comes back.
  const cached = unwrap(project.solve(undefined, "{}"));
  console.log(cached.baselineId === loaded.baselineId); // true

  // Edit, then reschedule.
  const report = unwrap(project.apply(JSON.stringify({ tasks: { update: [{ id: 1, duration: 4 }] } })));
  if (report.needsResync) throw new Error("edit rejected; resync with solve(dataJson, ...)");

  const after = unwrap(project.solve(undefined, "{}"));
  for (const update of after.updates) {
    console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
  }
} finally {
  project.free();
}
```

The last solve prints both tasks:

```
task 1: 2026-08-10 08:00 .. 2026-08-14 08:00
task 2: 2026-08-14 08:00 .. 2026-08-19 08:00
```

The two forms of [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) do different jobs:

- `solve(dataJson, …)` validates the replacement data, installs it, schedules it and accepts the dates as the new baseline. Use it on the first load and to resync the whole project. If validation fails, you get an error envelope and the previous state and cached result stay as they were.
- `solve(undefined, …)` (or `null`) schedules the current state. After an [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) it plans the edits. Tasks the edit touched (task 1 above) are reported too, even when the planner leaves them where the edit put them. On a clean project with the same options it returns the cached result without planning again.

The cache key is the whole [`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/solve-options.md>) object. Calling `solve(undefined, …)` with different options replans from the accepted dates. That doesn't change the data, so the [`baselineId`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#baselineid>) stays the same unless a task moves.

After a rejected `apply` operation, `solve(undefined, …)` returns a `project` error with code [`needs-resync`](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#recovering-from-needs-resync>) until you send the full data again with `solve(dataJson, …)`. Editing is covered in [Editing a project](<https://docs.dhtmlx.com/planning-engine/wasm/live-projects/editing-a-project.md>).

The [constructor](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#creating>) is the one call that throws on bad input, and it throws the error envelope as a string. The handle lives in WebAssembly memory until [`free()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#free>). See [Module and handles](<https://docs.dhtmlx.com/planning-engine/wasm/module-and-handles.md>).

## Solve options

[`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/solve-options.md>) changes what a solve schedules and what it reports. The defaults schedule the whole project and return only the moves. Option names are camelCase.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 3 },
    { id: 3, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 2, target: 3, type: "fs" }],
};

const project = new JsonProject("{}");
try {
  // Initial load: every task's dates, why each one sits where it does,
  // plus the critical path and slack.
  const options = {
    includeUnchanged: true,
    reasons: true,
    analytics: { criticalPath: true, slack: true },
  };
  const result = unwrap(project.solve(JSON.stringify(data), JSON.stringify(options)));
  for (const update of result.updates) {
    console.log(update.id, update.reason.kind);
  }

  // Later: reschedule only task 2 and what depends on it.
  unwrap(project.solve(undefined, JSON.stringify({ subtree: { task: 2 } })));
} finally {
  project.free();
}
```

The first solve reports all three tasks. Tasks 1 and 2 stay at their dates (`preferred`), and task 3 is pushed by link 1 (`asap`):

```json
{
  "duration": 1,
  "end_date": "2026-08-14 08:00",
  "id": 3,
  "kind": "task",
  "reason": { "kind": "asap", "linkId": 1, "sourceId": 2 },
  "start_date": "2026-08-13 08:00"
}
```

- `includeUnchanged` also reports tasks that didn't move, each with `start_date`, `end_date` and `duration` set. The result alone is then the full normalized date list, which is what an editor needs on first load. Unscheduled tasks are never reported. See [Load all task dates](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/load-all-task-dates.md>).
- `reasons` adds a [`reason`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#reason>) to each update: pushed by a predecessor link (`asap`), pulled by a successor (`alap`), moved with its summary block (`summary-translation`), placed by its constraint (`constraint`) or kept at its preferred date (`preferred`).
- `analytics` asks for the critical path, slack, or both. They're off by default. See [Critical path and slack](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/critical-path-and-slack.md>).
- [`subtree`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#subtree>) limits the run to one task and what depends on it. `inclusive` defaults to `true`, so the root task is scheduled too. The subtree applies to this call only. A scoped solve accepts only its own moves, and a later unscoped solve picks up the rest. An unknown or unscheduled subtree task is a `validation` error at `subtree.task`, and the cached result is kept. See [Reschedule a branch](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/reschedule-a-branch.md>).

With `subtree` and `includeUnchanged` together, the result holds every task in the subtree (with their ancestors) plus the out-of-subtree tasks that changed or were touched by an edit.

`level` and `preview` don't inherit these options: [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) solves with defaults first, and [`preview`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>) takes its own [`PreviewOptions`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md#previewoptions>).

## Forward and backward planning

By default the planner works forward: each task starts as early as its links allow. Set [`config.schedule_from_end`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>) to plan backward instead: each task ends as late as its successors allow, and the project is anchored at its end.

```js
const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  config: {
    schedule_from_end: true,
    gap_behavior: "compress",
    project_end: "2026-08-28 17:00",
  },
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-12 08:00", duration: 3 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
}
```

Task 2 has no successor, so `project_end` caps it and it ends at `2026-08-28 17:00`. Task 1 ends where task 2 starts, on `2026-08-25 17:00`.

The project bounds act only on tasks at the open ends of the graph:

- `project_start` (forward) applies to tasks without predecessors. With `gap_behavior: "compress"` it pulls them onto the project start. With `"preserve"` (the default) it only raises tasks that start earlier and leaves later ones at their dates. For a task with predecessors it's a hard floor only when `apply_constraints` is on.
- `project_end` (backward) caps tasks without successors, in either gap mode. Without it, such a task keeps its current end.

The full `config` reference is on [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>).

A task whose constraint points the other way gets a secondary pass in the opposite direction. In a forward project with `apply_constraints`, an `alap` task moves as late as its successors allow:

```js
const { schedule } = require("./pkg-node/plen_wasm.js");

// "order" can happen any time before "install"; "build" sets the pace.
const data = {
  config: { apply_constraints: true },
  tasks: [
    { id: "build", start_date: "2026-08-10 08:00", duration: 4 },
    { id: "order", start_date: "2026-08-10 08:00", duration: 1, constraint_type: "alap" },
    { id: "install", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [
    { id: "l1", source: "build", target: "install", type: "fs" },
    { id: "l2", source: "order", target: "install", type: "fs" },
  ],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
}
```

`install` waits for `build` and starts on `2026-08-14 08:00`. `order` moves from Monday to `2026-08-13 08:00`, so it ends right when `install` starts.

Leveling is forward-only. [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) on a project with `schedule_from_end: true` returns a `validation` error at `config.schedule_from_end`. Constraint types and summary behavior are covered in [Links, constraints and summaries](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/links-constraints-summaries.md>).

## Determinism and convergence

The same input and options always give the same result. The engine reads no clock and uses no randomness. It iterates in input order and topological order, never in hash order. That makes results safe to cache, diff and compare in tests.

The one exception is [`baselineId`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#baselineid>). Tokens are opaque, and two [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) calls on the same data can return different ones. Never compare them across runs or store them. Take the current one from the latest [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) result. To compare two envelopes, drop `result.baselineId` first.

The planner runs passes until the dates stop changing. `result.converged` tells you whether it reached that point within `config.max_iterations` passes:

- An unscoped run converges in two passes by design. The second pass reproduces the first.
- A scoped run (`subtree`) can need more passes, because tasks outside the scope feed into it.
- `max_iterations` defaults to 8 and must be at least 1. `0` is a `validation` error at `config.max_iterations`.

```js
const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  config: { max_iterations: 4 },
  tasks: [{ id: 1, start_date: "2026-08-10 08:00", duration: 1 }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
const result = response.result;
if (result.cycles.length > 0) {
  console.log(`dependency cycles: ${result.cycles.length}`);
} else if (!result.converged) {
  console.log("planner stopped after max_iterations");
}
```

`converged: false` isn't an error, and the result is still valid.

A dependency cycle stops the run before planning. Dates stay unchanged, `updates` is empty and `cycles` lists each unique [cycle](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#cycle>), for example `[{"links": [1, 2], "tasks": [1, 2]}]`. For everything else a result carries, see [Reading results](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/reading-results.md>).
