Skip to main content

Temporal vs Restate vs Windmill: Durable Workflow Orchestration (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.