Skip to content

Inngest vs Temporal: Which Fits Agent Workflows?

Inngest vs Temporal comparison for durable agent workflows and developer experience

Inngest and Temporal both help you run workflows that survive failures. The main difference is operational. Inngest invokes HTTP functions and centers its model on events and persisted steps. Temporal deploys workers that execute workflows and activities. That distinction affects local development, recovery, observability, and cost. The right choice depends less on feature checklists than on your team’s ability to operate the runtime. Here is a practical comparison based on one agent workflow.

The shared AI-agent workflow and fair comparison method

Start with the same workflow in both systems:

  1. Receive an event.
  2. Call an LLM.
  3. invoke an external tool.
  4. Wait for human approval.
  5. Retry a failed publishing API.
  6. Publish the final result.

A realistic example is a competitor-monitoring agent. A webhook reports a changed page. The agent summarizes the change, enriches it through an approved API, pauses for editorial review, and publishes an internal report.

Keep the business logic independent from the orchestrator:

from dataclasses import dataclass
from hashlib import sha256
import json


@dataclass(frozen=True)
class AgentRequest:
    event_id: str
    source_url: str
    content: str


def idempotency_key(event_id: str, operation: str) -> str:
    value = f"{event_id}:{operation}".encode()
    return sha256(value).hexdigest()


def build_publish_payload(request: AgentRequest, summary: str) -> dict:
    return {
        "idempotency_key": idempotency_key(
            request.event_id,
            "publish-report",
        ),
        "source_url": request.source_url,
        "summary": summary,
    }


if __name__ == "__main__":
    request = AgentRequest(
        event_id="evt-demo",
        source_url="https://example.com/product",
        content="A product page changed.",
    )
    print(json.dumps(build_publish_payload(request, "Change detected."), indent=2))

Wrap these functions with Inngest steps or Temporal activities. Do not bury reusable domain logic inside either SDK.

Then inject the same faults:

  • Terminate execution after the LLM response.
  • Force the tool API to time out.
  • deliver the trigger event again.
  • Leave approval pending.
  • Return a permanent publishing error.

For every run, record:

{
  "run_id": "candidate-run",
  "completed_steps": [],
  "duplicate_side_effects": [],
  "retries": [],
  "blocked_at": null,
  "recovery_duration_ms": null,
  "model_usage": {},
  "operator_actions": []
}

Compare completed steps, duplicate writes, recovery duration, end-to-end latency, resource use, and developer effort. Do not rely only on vendor feature pages.

For a deeper look at the underlying lifecycle, see this walkthrough of an event-driven workflow with queues, checkpoints, retries, and idempotency.

Inngest vs Temporal architecture and developer experience

The clearest architectural difference is the deployment unit.

According to Inngest’s comparison, Inngest invokes existing functions over HTTP. You do not run a separate worker process. Temporal uses workers as deployment units.

That changes your setup path. Inngest says you can add it to an existing function and start local development with:

npx inngest-cli@latest dev

Inngest’s vendor comparison characterizes its own startup time as “minutes” and Temporal’s as “hours to days.” Treat this as a vendor-provided onboarding scenario, not a universal benchmark. Your result will depend on existing infrastructure, deployment standards, and Temporal experience.

The programming models also differ.

In Inngest, step.run() wraps asynchronous work that should be persisted. The vendor’s example keeps ordinary async/await flow while checkpointing step results. Temporal separates workflow definitions from activities. Its workflow code also comes with determinism rules, which Inngest presents as additional cognitive overhead.

Here is a minimal Inngest implementation of the shared workflow. The LLM and tool calls are local stand-ins so the example runs without external credentials. The publishing step deliberately fails on the first two function attempts; Inngest retries the function while reusing the persisted results of completed step.run() calls.

// inngest-demo.ts
import express from "express";
import { Inngest } from "inngest";
import { serve } from "inngest/express";

const inngest = new Inngest({ id: "competitor-agent-demo" });

type PageChanged = {
  eventId: string;
  sourceUrl: string;
  content: string;
  publishFailuresBeforeSuccess?: number;
};

async function callLlm(content: string): Promise<string> {
  return `Summary: ${content}`;
}

async function callTool(summary: string): Promise<string> {
  return `${summary} | Enrichment: verified by demo tool`;
}

async function publishReport(input: {
  eventId: string;
  sourceUrl: string;
  report: string;
}): Promise<{ published: true; idempotencyKey: string }> {
  console.log("Published report:", input);
  return {
    published: true,
    idempotencyKey: `${input.eventId}:publish-report`,
  };
}

const competitorAgent = inngest.createFunction(
  {
    id: "competitor-monitoring-agent",
    retries: 3,
  },
  { event: "competitor/page.changed" },
  async ({ event, step, attempt }) => {
    const input = event.data as PageChanged;

    const summary = await step.run("summarize-with-llm", () =>
      callLlm(input.content),
    );

    const enrichedReport = await step.run("call-enrichment-tool", () =>
      callTool(summary),
    );

    const approval = await step.waitForEvent("wait-for-editorial-approval", {
      event: "competitor/report.approved",
      if: "async.data.eventId == event.data.eventId",
      timeout: "7d",
    });

    if (!approval) {
      throw new Error("Editorial approval timed out");
    }

    return step.run("publish-report", async () => {
      const failures = input.publishFailuresBeforeSuccess ?? 0;

      // Inngest attempts are zero-based. Throwing causes the function to retry;
      // earlier completed steps remain memoized.
      if (attempt < failures) {
        throw new Error(`Simulated publishing failure on attempt ${attempt}`);
      }

      return publishReport({
        eventId: input.eventId,
        sourceUrl: input.sourceUrl,
        report: enrichedReport,
      });
    });
  },
);

const app = express();
app.use(express.json());

app.post("/trigger", async (request, response) => {
  const eventId = request.body.eventId ?? "evt-demo";

  await inngest.send({
    name: "competitor/page.changed",
    data: {
      eventId,
      sourceUrl: "https://example.com/product",
      content: "A product page changed.",
      publishFailuresBeforeSuccess: 2,
    },
  });

  response.status(202).json({ eventId });
});

app.post("/approve/:eventId", async (request, response) => {
  await inngest.send({
    name: "competitor/report.approved",
    data: {
      eventId: request.params.eventId,
      approvedBy: "editor@example.com",
    },
  });

  response.status(202).json({ approved: true });
});

app.use(
  "/api/inngest",
  serve({
    client: inngest,
    functions: [competitorAgent],
  }),
);

app.listen(3000, () => {
  console.log("Inngest demo listening on http://localhost:3000");
});

Install and run it with the Inngest development server:

npm install inngest express
npm install --save-dev tsx typescript @types/express

INNGEST_DEV=1 npx tsx inngest-demo.ts
npx inngest-cli@latest dev -u http://localhost:3000/api/inngest

Then trigger and approve one run:

curl -X POST http://localhost:3000/trigger \
  -H 'content-type: application/json' \
  -d '{"eventId":"evt-demo"}'

curl -X POST http://localhost:3000/approve/evt-demo

The equivalent Temporal implementation places orchestration and approval handling in deterministic workflow code. LLM, tool, and publishing side effects live in a separate activities module. The worker executes both, while the client starts the workflow and sends its approval signal.

// activities.ts
import { activityInfo } from "@temporalio/activity";

export async function summarizeWithLlm(content: string): Promise<string> {
  return `Summary: ${content}`;
}

export async function callEnrichmentTool(summary: string): Promise<string> {
  return `${summary} | Enrichment: verified by demo tool`;
}

export async function publishReport(input: {
  eventId: string;
  sourceUrl: string;
  report: string;
  failuresBeforeSuccess: number;
}): Promise<{ published: true; idempotencyKey: string }> {
  const attempt = activityInfo().attempt;

  if (attempt <= input.failuresBeforeSuccess) {
    throw new Error(`Simulated publishing failure on attempt ${attempt}`);
  }

  console.log("Published report:", input);
  return {
    published: true,
    idempotencyKey: `${input.eventId}:publish-report`,
  };
}
// workflows.ts
import {
  condition,
  defineSignal,
  proxyActivities,
  setHandler,
} from "@temporalio/workflow";
import type * as activities from "./activities";

const { summarizeWithLlm, callEnrichmentTool, publishReport } =
  proxyActivities<typeof activities>({
    startToCloseTimeout: "30 seconds",
    retry: {
      maximumAttempts: 4,
    },
  });

export const approveReport = defineSignal("approveReport");

export type AgentInput = {
  eventId: string;
  sourceUrl: string;
  content: string;
  publishFailuresBeforeSuccess?: number;
};

export async function competitorAgentWorkflow(input: AgentInput) {
  let approved = false;
  setHandler(approveReport, () => {
    approved = true;
  });

  const summary = await summarizeWithLlm(input.content);
  const enrichedReport = await callEnrichmentTool(summary);

  const receivedApproval = await condition(() => approved, "7 days");
  if (!receivedApproval) {
    throw new Error("Editorial approval timed out");
  }

  return publishReport({
    eventId: input.eventId,
    sourceUrl: input.sourceUrl,
    report: enrichedReport,
    failuresBeforeSuccess: input.publishFailuresBeforeSuccess ?? 0,
  });
}
// worker.ts
import { NativeConnection, Worker } from "@temporalio/worker";
import * as activities from "./activities";

async function main() {
  const connection = await NativeConnection.connect();

  const worker = await Worker.create({
    connection,
    namespace: "default",
    taskQueue: "competitor-agent",
    workflowsPath: require.resolve("./workflows"),
    activities,
  });

  await worker.run();
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
// client.ts
import express from "express";
import { Client, Connection } from "@temporalio/client";
import { competitorAgentWorkflow } from "./workflows";

async function main() {
  const connection = await Connection.connect();
  const client = new Client({ connection });
  const app = express();

  app.use(express.json());

  app.post("/trigger", async (request, response) => {
    const eventId = request.body.eventId ?? "evt-demo";
    const workflowId = `competitor-agent-${eventId}`;

    await client.workflow.start(competitorAgentWorkflow, {
      taskQueue: "competitor-agent",
      workflowId,
      args: [
        {
          eventId,
          sourceUrl: "https://example.com/product",
          content: "A product page changed.",
          publishFailuresBeforeSuccess: 2,
        },
      ],
    });

    response.status(202).json({ eventId, workflowId });
  });

  app.post("/approve/:eventId", async (request, response) => {
    const workflowId = `competitor-agent-${request.params.eventId}`;
    const handle = client.workflow.getHandle(workflowId);

    await handle.signal("approveReport");
    response.status(202).json({ approved: true, workflowId });
  });

  app.listen(3001, () => {
    console.log("Temporal client listening on http://localhost:3001");
  });
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Install the SDKs, start a local Temporal server, and run the worker and client in separate terminals:

npm install @temporalio/activity @temporalio/client \
  @temporalio/worker @temporalio/workflow express
npm install --save-dev tsx typescript @types/express

temporal server start-dev
npx tsx worker.ts
npx tsx client.ts

Triggering and approving the Temporal workflow uses the same interaction pattern:

curl -X POST http://localhost:3001/trigger \
  -H 'content-type: application/json' \
  -d '{"eventId":"evt-demo"}'

curl -X POST http://localhost:3001/approve/evt-demo

That tradeoff can be useful rather than inherently negative. Explicit boundaries may fit a platform team that wants strict orchestration conventions. A product team adding durability to an HTTP application may prefer Inngest’s smaller initial change.

Language choice matters too. Inngest says it supports TypeScript, Python, Go, and environments that can expose HTTP endpoints. The same comparison describes Temporal’s Go SDK as its most mature SDK. Test the specific language and framework you use rather than treating “SDK available” as sufficient.

A useful developer-experience review should include:

  • Time from checkout to a successful local run.
  • Number of deployable components.
  • Local debugging quality.
  • How clearly workflow code is separated from side effects.
  • Deployment and rollback steps.
  • Required knowledge for the on-call engineer.

Failure recovery, durability, and long-running agent behavior

Durable execution does not make side effects safe automatically. Your database write, email call, or publishing request can succeed even if the orchestrator does not record the response.

Use an idempotency key for every externally visible operation:

PUBLISH_TIMEOUT_SECONDS = 30


def publish_report(client, event_id: str, report: dict):
    key = idempotency_key(event_id, "publish-report")
    return client.publish(
        report=report,
        idempotency_key=key,
        timeout_seconds=PUBLISH_TIMEOUT_SECONDS,
    )

The timeout value belongs in configuration; the constant above represents that explicit configuration value. The important part is that retries reuse the same key.

For the failure after the LLM call, inspect whether recovery reuses the completed result or calls the model again. Inngest claims step memoization, meaning a persisted step result can be reused when execution resumes. Verify this with your model client and failure test.

For the external API timeout, define:

  • An explicit timeout.
  • Retryable error classes.
  • Non-retryable error classes.
  • A maximum retry policy appropriate to the API.
  • An idempotency key accepted or enforced by the destination.
  • A terminal state that an operator can inspect.

For duplicate trigger events, store the source event ID. Reject or safely reuse an existing run. Orchestrator durability does not replace application-level deduplication.

For delayed approval, Inngest describes step.waitForEvent as a way to pause until another event arrives. Its event model also supports fan-out and coordination between events, including workflows paused for days or weeks. The same Hacker News discussion says concurrency and rate-limit controls are configurable to avoid overwhelming downstream systems.

Whichever system you choose, test:

  • Cancellation while approval is pending.
  • Deployment of changed workflow code while old runs still exist.
  • Permanent tool failures.
  • Partial side effects.
  • Replayed or duplicated events.
  • Approval arriving after cancellation.

Versioning is especially important for long-running agents. A workflow started under one release may resume after you deploy another. Define how old runs retain compatible behavior before production traffic exposes the problem.

Observability and operating an agent in production

A workflow is not operable just because it eventually retries. You need to explain what it is doing now.

For the shared test, your dashboard or telemetry stack should answer:

  • Which step is running?
  • Which steps have completed?
  • Why is the workflow blocked?
  • How many retries occurred?
  • Which external dependency failed?
  • Did the LLM call complete?
  • Was a side effect duplicated?
  • What did the run cost?
  • Can an operator cancel or resume it safely?

Inngest claims per-LLM-call observability and human-intervention features designed for agents. Validate what information is captured automatically. Check whether model inputs, outputs, usage metadata, errors, and latency appear at the level your team needs.

For Temporal, treat worker logs, metrics, traces, workflow inspection, and model-provider telemetry as evaluation axes to verify rather than capabilities assumed to be available in every deployment. Their availability and level of integration depend on the SDK, backend, deployment model, and instrumentation you choose. The key questions are which signals your implementation exposes, how much setup they require, and how much ongoing ownership your production standard demands.

Use a correlation ID across the trigger, workflow, model call, tool request, approval event, and final publication:

import logging

logger = logging.getLogger("agent")


def log_step(run_id: str, step: str, status: str) -> None:
    logger.info(
        "agent_step",
        extra={
            "run_id": run_id,
            "step": step,
            "status": status,
        },
    )

Keep secrets and sensitive model data out of logs. Store references when full payloads are unnecessary.

At higher traffic, orchestration is only one concern. Queue pressure, tenant isolation, model budgets, and downstream limits also determine reliability. Our guide to scaling agents through queues, observability, cost control, and isolation covers that wider operating context.

Total cost: platform pricing, infrastructure, and engineering time

There is not enough evidence here for a trustworthy price benchmark. Build a cost model with your workload instead.

Use this structure:

monthly total =
    managed platform fees
  + worker or function compute
  + database and durable storage
  + network traffic
  + logs, metrics, and traces
  + model and tool API usage
  + engineering maintenance
  + on-call response
  + upgrade and migration work

Inngest’s HTTP model can reduce the initial infrastructure burden because it does not require a separate worker process. Temporal introduces worker deployment and operation. With self-hosting, also account for the runtime’s supporting infrastructure and upgrades. With a managed service, verify current pricing and included usage directly before deciding.

Model several workload shapes:

  • Frequent short runs.
  • Infrequent workflows that wait for approval.
  • Tool-heavy runs with repeated retries.
  • Bursty event fan-out.
  • Long-running workflows spanning deployments.

Engineering time can dominate early. Infrastructure can dominate later. Include the cost of local setup, CI, deployment, dashboards, incident response, and workflow migrations.

Your hosting model also affects the result. Scheduling, execution isolation, secrets, and deployment remain necessary around the orchestrator. See the broader agent workflow build, scheduling, and deployment guide when defining those boundaries.

Which should your team choose? A three-profile recommendation

Small serverless team: choose Inngest when you want event-driven delivery through existing HTTP functions and minimal platform work. Its persisted steps, ordinary asynchronous code, and local CLI provide a direct adoption path.

Platform team with infrastructure expertise: choose Temporal when your team is prepared to own worker deployment and prefers explicit workflow and activity boundaries. Validate that the additional control is worth the operational investment for your coordination and scale requirements.

Product team building agents: start with Inngest when iteration speed, human approval, persisted steps, and LLM-level visibility are the main concerns. Reassess as workflow count, coordination requirements, and platform ownership grow.

The Monk migration is a useful caution, not a universal verdict. Monk reported operating more than a hundred asynchronous jobs with Inngest before moving to Temporal. The team said Inngest helped it move quickly initially but became problematic at its scale.

Monk kept business logic in shared services while Temporal activities and Inngest jobs acted as wrappers. It ran both systems during the transition, used reversible pull requests, and routed a tenant canary through a feature flag. Each workflow followed a four-stage migration process. After standardization, Monk reported that migrating one workflow took one to two days.

Before committing, answer these questions:

  • Do you want HTTP functions or dedicated workers?
  • Who owns runtime upgrades and on-call support?
  • Can every external side effect be made idempotent?
  • How will you handle old workflows after code changes?
  • Can operators inspect blocked approvals and failed tools?
  • Can you attribute model and infrastructure cost per run?
  • Does the preferred SDK fit your team’s language?
  • Have you tested recovery by killing a real execution?

Conclusion

Inngest optimizes for adding durable, event-driven steps to HTTP applications with less initial infrastructure. Temporal asks you to adopt a worker-based model with explicit workflow boundaries. Neither choice removes the need for idempotency, timeouts, versioning, cancellation, telemetry, and safe side effects.

Implement the shared agent workflow in both. Kill it after the model call. Duplicate its event. Delay approval. Measure what your team must deploy, debug, and operate. Once orchestration is chosen, define the surrounding deployment, scheduling, secrets-management, and isolation layer according to your security, reliability, and operational requirements.