Skip to main content

Guide

Temporal vs Restate vs Windmill 2026

Compare Temporal, Restate, and Windmill for durable workflow orchestration. Reliable execution, retries, long-running workflows, and which orchestration.

·PkgPulse Team·
0

TL;DR

Temporal is the durable execution platform — workflows as code, automatic retries, timers, signals, queries, the most battle-tested workflow orchestration. Restate is the durable execution engine — lightweight, SDK-native, virtual objects, journaled execution, no separate server for simple setups. Windmill is the developer-first workflow platform — scripts as workflows, UI builder, schedules, approval flows, self-hosted, TypeScript/Python/Go. In 2026: Temporal for mission-critical durable workflows, Restate for lightweight durable execution, Windmill for script-based automation.

Key Takeaways

  • Temporal: @temporalio/client ~25K weekly downloads — durable execution, battle-tested
  • Restate: @restatedev/restate-sdk ~3K weekly downloads — lightweight, journaled
  • Windmill: 12K+ GitHub stars — scripts, UI builder, schedules, approval flows
  • Temporal provides the most robust durable execution for complex workflows
  • Restate offers the lightest-weight approach to durable functions
  • Windmill combines workflow orchestration with a script management UI

Temporal

Temporal — durable execution platform:

Setup

npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
# Start Temporal server (development):
temporal server start-dev

Workflow definition

// src/workflows.ts — deterministic workflow code
import { proxyActivities, sleep, condition, setHandler, defineSignal, defineQuery } from "@temporalio/workflow"
import type * as activities from "./activities"

const { fetchNpmData, updateDatabase, sendNotification, publishToRegistry } =
  proxyActivities<typeof activities>({
    startToCloseTimeout: "30s",
    retry: {
      maximumAttempts: 3,
      initialInterval: "1s",
      backoffCoefficient: 2,
    },
  })

// Signal and query definitions:
export const approvalSignal = defineSignal<[boolean]>("approval")
export const statusQuery = defineQuery<string>("status")

// Package publish workflow:
export async function publishPackageWorkflow(
  packageName: string,
  version: string,
  publisherId: string
): Promise<{ success: boolean; publishedAt?: string }> {
  let status = "validating"
  let approved: boolean | undefined

  // Set up signal handler for approval:
  setHandler(approvalSignal, (isApproved: boolean) => {
    approved = isApproved
  })

  // Set up query handler:
  setHandler(statusQuery, () => status)

  // Step 1: Fetch and validate npm data
  status = "fetching-data"
  const npmData = await fetchNpmData(packageName)

  if (!npmData) {
    status = "failed-validation"
    return { success: false }
  }

  // Step 2: Update database
  status = "updating-database"
  await updateDatabase(packageName, version, npmData)

  // Step 3: Wait for approval (up to 24 hours)
  status = "awaiting-approval"
  await sendNotification(
    publisherId,
    `Package ${packageName}@${version} ready for approval`
  )

  // Wait for signal or timeout:
  const gotApproval = await condition(() => approved !== undefined, "24h")

  if (!gotApproval || !approved) {
    status = "rejected"
    await sendNotification(publisherId, `Package ${packageName}@${version} was ${gotApproval ? "rejected" : "timed out"}`)
    return { success: false }
  }

  // Step 4: Publish
  status = "publishing"
  await publishToRegistry(packageName, version)

  // Step 5: Notify
  status = "completed"
  await sendNotification(
    publisherId,
    `Package ${packageName}@${version} published successfully!`
  )

  return { success: true, publishedAt: new Date().toISOString() }
}

// Scheduled data sync workflow:
export async function packageSyncWorkflow(packages: string[]): Promise<void> {
  for (const pkg of packages) {
    const data = await fetchNpmData(pkg)
    if (data) {
      await updateDatabase(pkg, data.version, data)
    }
    // Sleep between requests to avoid rate limiting:
    await sleep("2s")
  }
}

Activities

// src/activities.ts — non-deterministic code (I/O, APIs)
export async function fetchNpmData(packageName: string) {
  const response = await fetch(`https://registry.npmjs.org/${packageName}`)
  if (!response.ok) return null

  const data = await response.json()
  return {
    name: data.name,
    version: data["dist-tags"].latest,
    description: data.description,
    downloads: 0, // Fetched separately
  }
}

export async function updateDatabase(
  packageName: string,
  version: string,
  data: any
) {
  await db.query(
    `INSERT INTO packages (name, version, description, updated_at)
     VALUES ($1, $2, $3, NOW())
     ON CONFLICT (name) DO UPDATE SET version = $2, description = $3, updated_at = NOW()`,
    [packageName, version, data.description]
  )
}

export async function sendNotification(userId: string, message: string) {
  await fetch("https://api.example.com/notify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, message }),
  })
}

export async function publishToRegistry(packageName: string, version: string) {
  // Simulate publishing:
  console.log(`Publishing ${packageName}@${version}`)
  await new Promise((r) => setTimeout(r, 5000))
}

Worker and client

// src/worker.ts — runs activities and workflows
import { Worker } from "@temporalio/worker"
import * as activities from "./activities"

async function run() {
  const worker = await Worker.create({
    workflowsPath: require.resolve("./workflows"),
    activities,
    taskQueue: "package-management",
  })

  await worker.run()
}

run()

// src/client.ts — start and interact with workflows
import { Client } from "@temporalio/client"
import { publishPackageWorkflow, approvalSignal, statusQuery } from "./workflows"

const client = new Client()

// Start workflow:
const handle = await client.workflow.start(publishPackageWorkflow, {
  taskQueue: "package-management",
  workflowId: `publish-react-19.1.0`,
  args: ["react", "19.1.0", "user-123"],
})

console.log(`Started workflow: ${handle.workflowId}`)

// Query status:
const status = await handle.query(statusQuery)
console.log(`Status: ${status}`)

// Send approval signal:
await handle.signal(approvalSignal, true)

// Wait for result:
const result = await handle.result()
console.log(`Result:`, result)

// Schedule recurring workflow:
await client.schedule.create({
  scheduleId: "daily-package-sync",
  spec: { intervals: [{ every: "6h" }] },
  action: {
    type: "startWorkflow",
    workflowType: "packageSyncWorkflow",
    taskQueue: "package-management",
    args: [["react", "vue", "svelte", "angular"]],
  },
})

Restate

Restate — durable execution engine:

Setup

npm install @restatedev/restate-sdk

# Start Restate server:
docker run -d --name restate -p 8080:8080 -p 9070:9070 docker.io/restatedev/restate:latest

Services and handlers

// src/services/packageService.ts
import * as restate from "@restatedev/restate-sdk"

const packageService = restate.service({
  name: "packageService",
  handlers: {
    // Durable function — automatically retried, journaled:
    async publishPackage(
      ctx: restate.Context,
      request: { name: string; version: string; publisherId: string }
    ) {
      const { name, version, publisherId } = request

      // Step 1: Fetch npm data (side effect — journaled):
      const npmData = await ctx.run("fetch-npm", async () => {
        const res = await fetch(`https://registry.npmjs.org/${name}`)
        return res.json()
      })

      // Step 2: Update database (side effect):
      await ctx.run("update-db", async () => {
        await db.query(
          `INSERT INTO packages (name, version, description, updated_at)
           VALUES ($1, $2, $3, NOW())
           ON CONFLICT (name) DO UPDATE SET version = $2, updated_at = NOW()`,
          [name, version, npmData.description]
        )
      })

      // Step 3: Send notification:
      await ctx.run("notify", async () => {
        await fetch("https://api.example.com/notify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            userId: publisherId,
            message: `${name}@${version} published!`,
          }),
        })
      })

      return { success: true, publishedAt: new Date().toISOString() }
    },

    // Workflow with sleep (durable timer):
    async scheduledSync(ctx: restate.Context, packages: string[]) {
      for (const pkg of packages) {
        const data = await ctx.run(`fetch-${pkg}`, async () => {
          const res = await fetch(`https://registry.npmjs.org/${pkg}`)
          return res.json()
        })

        await ctx.run(`update-${pkg}`, async () => {
          await db.query(
            "UPDATE packages SET version = $1 WHERE name = $2",
            [data["dist-tags"].latest, pkg]
          )
        })

        // Durable sleep — survives restarts:
        await ctx.sleep(2000)
      }
    },
  },
})

export default packageService

Virtual objects (stateful entities)

// src/services/packageTracker.ts
import * as restate from "@restatedev/restate-sdk"

const packageTracker = restate.object({
  name: "packageTracker",
  handlers: {
    // Each package gets its own virtual object (keyed by package name):
    async recordDownload(ctx: restate.ObjectContext, count: number) {
      // Get current state:
      const current = (await ctx.get<number>("totalDownloads")) ?? 0
      const newTotal = current + count

      // Update state (durable):
      ctx.set("totalDownloads", newTotal)

      // Check milestone:
      if (current < 1000000 && newTotal >= 1000000) {
        await ctx.run("milestone-notification", async () => {
          await fetch("https://api.example.com/notify", {
            method: "POST",
            body: JSON.stringify({
              message: `${ctx.key} hit 1M downloads!`,
            }),
          })
        })
      }

      return { totalDownloads: newTotal }
    },

    async getStats(ctx: restate.ObjectSharedContext) {
      const totalDownloads = (await ctx.get<number>("totalDownloads")) ?? 0
      return { package: ctx.key, totalDownloads }
    },
  },
})

export default packageTracker

Server and client

// src/app.ts — register services
import * as restate from "@restatedev/restate-sdk"
import packageService from "./services/packageService"
import packageTracker from "./services/packageTracker"

restate
  .endpoint()
  .bind(packageService)
  .bind(packageTracker)
  .listen(9080)

// Register with Restate server:
// curl http://localhost:9070/deployments -H 'content-type: application/json' \
//   -d '{"uri": "http://localhost:9080"}'
// Client — call services:
import * as restate from "@restatedev/restate-sdk-clients"

const client = restate.connect({ url: "http://localhost:8080" })

// Call service handler:
const result = await client
  .serviceClient(packageService)
  .publishPackage({
    name: "react",
    version: "19.1.0",
    publisherId: "user-123",
  })

// Call virtual object:
const stats = await client
  .objectClient(packageTracker, "react")
  .getStats()

// Send (fire and forget):
await client
  .objectSendClient(packageTracker, "react")
  .recordDownload(50000)

Windmill

Windmill — developer-first workflow platform:

Setup

# Docker Compose:
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
docker compose up -d

# Access: http://localhost:8000

Scripts (building blocks)

// Script: "fetch_npm_data" (TypeScript)
// Runs as a standalone script or workflow step

export async function main(packageName: string): Promise<{
  name: string
  version: string
  description: string
  downloads: number
}> {
  // Fetch package info:
  const registryRes = await fetch(`https://registry.npmjs.org/${packageName}`)
  const registry = await registryRes.json()

  // Fetch download count:
  const downloadsRes = await fetch(
    `https://api.npmjs.org/downloads/point/last-week/${packageName}`
  )
  const downloads = await downloadsRes.json()

  return {
    name: registry.name,
    version: registry["dist-tags"].latest,
    description: registry.description,
    downloads: downloads.downloads,
  }
}
// Script: "update_package_db"
import { Client } from "pg"

export async function main(
  pgResource: object,  // Windmill resource (connection details)
  packageData: {
    name: string
    version: string
    description: string
    downloads: number
  }
) {
  const client = new Client(pgResource)
  await client.connect()

  await client.query(
    `INSERT INTO packages (name, version, description, downloads, updated_at)
     VALUES ($1, $2, $3, $4, NOW())
     ON CONFLICT (name) DO UPDATE SET
       version = $2, description = $3, downloads = $4, updated_at = NOW()`,
    [packageData.name, packageData.version, packageData.description, packageData.downloads]
  )

  await client.end()
  return { updated: packageData.name }
}

Flow (workflow)

// Flow: "package_sync_flow" (defined in Windmill UI or YAML)
// Steps are connected scripts

// Step 1: Fetch package list
// Script: inline
export async function main() {
  return {
    packages: ["react", "vue", "svelte", "angular", "next", "nuxt"],
  }
}

// Step 2: For-each loop (parallel)
// Iterator: results.packages
// Script: "fetch_npm_data"
// Input: { packageName: flow_input.iter.value }

// Step 3: Update database (for each result)
// Script: "update_package_db"
// Input: { pgResource: $res:pg_production, packageData: results[1] }

// Step 4: Send summary
// Script: inline
export async function main(
  slackResource: object,
  results: Array<{ updated: string }>
) {
  await fetch(slackResource.webhook_url, {
    method: "POST",
    body: JSON.stringify({
      text: `📦 Synced ${results.length} packages: ${results.map((r) => r.updated).join(", ")}`,
    }),
  })
}

Approval flows

// Script with approval step:
import * as wmill from "windmill-client"

export async function main(
  packageName: string,
  version: string,
  requesterId: string
) {
  // Fetch data:
  const res = await fetch(`https://registry.npmjs.org/${packageName}`)
  const data = await res.json()

  // Request approval (pauses workflow):
  const approved = await wmill.getResumeUrls("approval")

  // Send approval request:
  await fetch("https://api.example.com/notify", {
    method: "POST",
    body: JSON.stringify({
      to: "admin@example.com",
      message: `Approve publish of ${packageName}@${version}?`,
      approveUrl: approved.approvalPage,
    }),
  })

  // Workflow pauses here until approved/rejected
  // Windmill provides a UI for approval

  return { approved: true, package: packageName, version }
}

Schedules and webhooks

# Schedule via CLI:
wmill schedule create \
  --path "f/package-sync/package_sync_flow" \
  --schedule "0 */6 * * *" \
  --args '{}' \
  --summary "Sync packages every 6 hours"

# Webhook trigger:
# Each script/flow gets a webhook URL:
# POST https://windmill.example.com/api/w/workspace/jobs/run/f/package-sync/package_sync_flow
# Headers: Authorization: Bearer <token>
# Body: { "packages": ["react", "vue"] }
// Windmill client API:
import * as wmill from "windmill-client"

// Run a script:
const jobId = await wmill.runScript({
  path: "f/package-sync/fetch_npm_data",
  args: { packageName: "react" },
})

// Get result:
const result = await wmill.waitJob(jobId)
console.log(result)

// Run a flow:
const flowJobId = await wmill.runFlow({
  path: "f/package-sync/package_sync_flow",
  args: {},
})

Feature Comparison

FeatureTemporalRestateWindmill
LanguageTypeScript/Go/Java/PythonTypeScript/Java/KotlinTypeScript/Python/Go/Bash
Execution modelWorkflows + ActivitiesServices + Virtual ObjectsScripts + Flows
Durability✅ (event sourcing)✅ (journaling)✅ (job queue)
Automatic retries
Durable timers✅ (sleep)✅ (sleep)✅ (schedule)
Signals/events✅ (signals + queries)Via virtual objectsVia webhooks
State managementWorkflow stateVirtual object stateVariables
Approval flowsVia signalsCustom✅ (built-in)
Visual builder✅ (flow editor)
Scheduling✅ (schedules)Custom✅ (cron)
UI dashboard✅ (Temporal UI)✅ (dashboard)✅ (full IDE)
Self-hosted
Cloud hosted✅ (Temporal Cloud)✅ (Restate Cloud)✅ (Windmill Cloud)
ComplexityHigh (powerful)MediumLow (scripts)
ScaleEnterpriseService-levelTeam/mid-size
LicenseMITBSLAGPLv3

When to Use Each

Use Temporal if:

  • Building mission-critical workflows that must never lose state
  • Need complex workflow patterns (signals, queries, child workflows, sagas)
  • Want the most battle-tested durable execution platform
  • Building enterprise-scale orchestration with Go/Java/TypeScript

Use Restate if:

  • Want lightweight durable execution without heavy infrastructure
  • Need virtual objects for stateful entity management
  • Prefer SDK-native approach (durable functions in your existing services)
  • Building microservices that need reliable communication

Use Windmill if:

  • Want a visual flow builder with script-based workflow steps
  • Need approval flows, schedules, and webhook triggers out of the box
  • Prefer a platform where scripts are the building blocks
  • Building internal automation with a full web IDE for editing workflows

Production Infrastructure and Self-Hosting

Deploying durable workflow systems in production requires understanding each platform's infrastructure dependencies. Temporal requires running the Temporal server, which itself depends on a database backend (PostgreSQL, MySQL, or Cassandra) and an optional Elasticsearch cluster for workflow visibility and search. The temporal server start-dev command used in development runs an in-memory instance that loses state on restart — production deployments use Helm charts or Docker Compose with persistent storage. Temporal Cloud eliminates this operational burden entirely at the cost of a usage-based pricing model that scales with workflow executions and storage. Restate is architecturally lighter — it acts as a proxy layer in front of your existing services, storing journal state in its own embedded RocksDB, and can be deployed as a single binary or Docker container without additional database dependencies. Windmill stores workflow state and script history in PostgreSQL, which most infrastructure teams already operate, making self-hosting relatively straightforward.

TypeScript Integration and Workflow Determinism

Temporal's most important TypeScript constraint is workflow determinism: workflow code must be deterministic between replays. This means no Date.now(), no Math.random(), no direct network calls, and no imports of non-deterministic Node.js modules in workflow files. These restrictions exist because Temporal replays workflow history during recovery, and non-deterministic operations would produce different results on replay. Temporal's TypeScript SDK enforces this by running workflow code in a sandboxed isolate that intercepts non-deterministic APIs. The activity pattern (separating I/O into activities) is the correct workaround, but it requires developers to internalize this mental model. Restate's ctx.run() pattern is conceptually similar but more explicit — every side effect is wrapped in a named run block that gets journaled, and the rest of your handler code runs normally without sandboxing restrictions. This makes Restate's model easier to reason about for developers unfamiliar with event-sourced execution.

Workflow Observability and Debugging

Understanding what your workflows are doing in production is critical for operating durable execution systems. Temporal provides a web UI (Temporal UI) that shows all running and historical workflows, their current state, activity history, and any failures with stack traces. You can search workflows by ID, status, or custom attributes, and the event history viewer shows every step the workflow executed in sequence. The Temporal CLI also supports workflow describe and workflow show commands for operational inspection. Restate's observability story is more primitive in 2026 — its dashboard shows service invocations and their states, but the depth of introspection is less mature than Temporal's. Windmill provides rich execution logs through its web IDE, with per-step output capture and error visualization, making it particularly accessible for non-developer operators who need to understand why a workflow failed.

Security and Secrets Management

All three platforms need access to secrets for the activities and scripts they run. Temporal recommends using environment variables injected into your Worker process for secrets, keeping them out of workflow arguments that get logged and stored in history. Activity code reads secrets from the environment directly, which is the standard twelve-factor pattern. Restate similarly relies on secrets being available in the environment of your deployed service handlers, with no built-in secrets management. Windmill has the most sophisticated secrets story of the three — it provides a first-class secrets/variables system where encrypted values are stored in Windmill's database and injected into scripts at runtime, with a web UI for managing them and role-based access control over which users can see which secrets. This makes Windmill particularly attractive for teams where non-developers need to manage API keys and credentials for automation scripts without needing access to deployment infrastructure.

Choosing the Right Abstraction Level

The choice between these three platforms often comes down to how much of the workflow engine you want to be explicit about versus how much you want abstracted. Temporal gives you the most control and the most visibility, but at the cost of learning its determinism model and operating its infrastructure. It is the right choice when correctness guarantees are non-negotiable and your team has the engineering capacity to use it properly. Restate sits at a higher abstraction level, removing the determinism constraint by making side effects explicit via ctx.run(), and requires less infrastructure knowledge to operate. It fits teams that want durable execution without committing to the full Temporal mental model. Windmill abstracts workflow orchestration the most — scripts are the unit of composition, not code constructs — making it accessible to operators and data engineers who write Python or TypeScript scripts rather than framework-specific workflow definitions. The right choice depends on whether your primary users are developers (Temporal or Restate) or operators and analysts (Windmill).

Testing Durable Workflows in CI

Testing durable workflow logic without requiring a live Temporal or Restate cluster in CI is an active area of tooling development. Temporal provides a TestWorkflowEnvironment that runs an in-process Temporal server suitable for unit and integration tests — workflows and activities execute synchronously in test mode, time can be skipped programmatically, and mocking individual activities is straightforward via testEnv.client. This makes Temporal one of the more testable durable workflow systems because the entire execution model can be validated without infrastructure. Restate's service handlers are plain async functions, which means they can be unit tested directly without a Restate server — the ctx object can be mocked to control what ctx.run() returns, making activity outcomes controllable in tests. Windmill's scripts are similarly plain functions that can be tested in isolation, though integration testing a full Windmill flow requires a running Windmill instance. For all three platforms, integration testing against a local containerized instance (using docker compose) is the recommended approach for end-to-end testing of multi-step workflows, with CI pipelines allocating additional time for the platform startup before test execution begins.


Methodology

Download data from npm registry and GitHub (March 2026). Feature comparison based on @temporalio/client v1.x, @restatedev/restate-sdk v1.x, and Windmill v1.x.

Compare workflow tools and developer platforms on PkgPulse →

See also: AVA vs Jest and Payload CMS vs Strapi vs Directus, amqplib vs KafkaJS vs Redis Streams.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.