Skip to content

Fix: Temporal Not Working — Workflows Not Starting, Activities Failing, or Worker Not Connecting

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Temporal issues — worker setup, workflow and activity errors, schedule configuration, versioning, and self-hosted Temporal Server deployment.

The Problem

A Temporal workflow fails to start with a connection error:

Error: 14 UNAVAILABLE: Connection refused
  at temporal.io/temporal-workflow:start

Or an activity throws and the workflow gets stuck in a retry loop:

Activity sendEmail failed: Error: SMTP connection refused
Retrying activity (attempt 3/unlimited)...

Or the Worker starts but workflows queued in the Task Queue never execute:

Worker connected to Temporal server
// Workflows enqueued but never picked up — worker runs idle

Why This Happens

Temporal’s programming model is different from typical job queues. It’s not a queue, it’s a durable execution engine — every workflow step is persisted to the Temporal Server’s database, and the SDK can replay your workflow code from scratch to reconstruct state after a crash. That replay model is what makes Temporal reliable, but it’s also the source of most “why isn’t this working” questions. Understanding the architecture prevents most errors.

  • The Worker must listen on the same Task Queueclient.workflow.start() routes work to a Task Queue. If the Worker isn’t polling that exact queue name, the workflow sits forever. The SDK does not warn you about an unclaimed Task Queue — from the client’s perspective, the workflow was accepted; it’s just never picked up.
  • Workflows are deterministic — activities are not — workflow code must produce the same result on replay. Network calls, random numbers, and Date.now() inside workflow code cause non-determinism errors. Put all side effects in activities. The Temporal SDK wraps setTimeout, Math.random, and date access with deterministic equivalents (sleep(), workflow.random(), workflow.now()), but only if you use those wrappers.
  • Activity timeouts and retries are separate — by default, Temporal retries activities indefinitely with exponential backoff. A bug in your activity code causes infinite retries, not a failure. The defaults are conservative because Temporal’s philosophy is “retries are cheaper than alerts,” but in development that translates to “your bug runs forever.”
  • The Temporal Server is not included — you must run a Temporal Server separately (locally via Docker, or use Temporal Cloud). The SDK connects to it; without the server, nothing works. This is unlike BullMQ or Inngest, which embed their queue logic in the Node process or run as a hosted service with zero infra.

A subtler issue: the Worker bundles the workflow code at startup. If you change workflow code and only restart the client, the Worker still runs the old code. Always restart the Worker after any change to files under workflowsPath. And the bundle is sticky per Worker process — you can’t hot-reload a workflow definition without recycling the Worker.

Fix 1: Local Development Setup

# Start Temporal Server locally with Docker
docker run --rm -p 7233:7233 -p 8233:8233 temporalio/auto-setup:latest

# Or use the Temporal CLI (temporalite)
brew install temporal
temporal server start-dev
# Temporal UI: http://localhost:8233
# gRPC: localhost:7233
# npm install
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
npm install --save-dev @temporalio/testing

Fix 2: Project Structure and Worker

// src/temporal/activities.ts — all side effects go here
import { Context } from '@temporalio/activity';

export async function sendWelcomeEmail(email: string, name: string): Promise<void> {
  // Check for cancellation periodically in long-running activities
  Context.current().heartbeat();

  await emailService.send({
    to: email,
    subject: 'Welcome!',
    body: `Hello ${name}, welcome to our app!`,
  });
}

export async function createUserRecord(userId: string, data: UserData): Promise<User> {
  return db.insert(users).values({ id: userId, ...data }).returning()[0];
}

export async function chargePayment(userId: string, amount: number): Promise<string> {
  const charge = await stripe.charges.create({ amount, currency: 'usd', customer: userId });
  return charge.id;
}
// src/temporal/workflows.ts — deterministic orchestration only
import { proxyActivities, sleep, condition, defineSignal, setHandler } from '@temporalio/workflow';
import type * as activities from './activities';

// Import activities through proxy — never import directly
const { sendWelcomeEmail, createUserRecord, chargePayment } = proxyActivities<typeof activities>({
  startToCloseTimeout: '30 seconds',
  retry: {
    maximumAttempts: 3,
    nonRetryableErrorTypes: ['PaymentDeclined'],
  },
});

export const cancelOrderSignal = defineSignal<[reason: string]>('cancelOrder');

export async function onboardUserWorkflow(userId: string, email: string, name: string): Promise<void> {
  // Step 1: Create the user record
  await createUserRecord(userId, { email, name });

  // Step 2: Wait 1 second then send the welcome email
  await sleep('1 second');
  await sendWelcomeEmail(email, name);

  // Workflow completes — Temporal marks it as done
}

export async function processOrderWorkflow(orderId: string, userId: string, amount: number): Promise<string> {
  let cancelled = false;
  let cancelReason = '';

  setHandler(cancelOrderSignal, (reason: string) => {
    cancelled = true;
    cancelReason = reason;
  });

  // Wait up to 5 minutes for payment, or until cancelled
  const completed = await condition(() => cancelled, '5 minutes');

  if (cancelled) {
    throw new Error(`Order cancelled: ${cancelReason}`);
  }

  const chargeId = await chargePayment(userId, amount);
  return chargeId;
}
// src/worker.ts — run this process separately
import { Worker } from '@temporalio/worker';
import * as activities from './temporal/activities';

async function run() {
  const worker = await Worker.create({
    workflowsPath: require.resolve('./temporal/workflows'),
    activities,
    taskQueue: 'main-queue',  // Must match what the client uses
    connection: {
      address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
    },
    namespace: process.env.TEMPORAL_NAMESPACE ?? 'default',
  });

  await worker.run();
}

run().catch((err) => {
  console.error('Worker error:', err);
  process.exit(1);
});

Fix 3: Client — Starting and Querying Workflows

// src/temporal/client.ts
import { Client, Connection } from '@temporalio/client';

let _client: Client | null = null;

export async function getClient(): Promise<Client> {
  if (_client) return _client;

  const connection = await Connection.connect({
    address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
  });

  _client = new Client({
    connection,
    namespace: process.env.TEMPORAL_NAMESPACE ?? 'default',
  });

  return _client;
}
// Starting a workflow
import { getClient } from './temporal/client';
import type { onboardUserWorkflow } from './temporal/workflows';

const client = await getClient();

const handle = await client.workflow.start(onboardUserWorkflow, {
  taskQueue: 'main-queue',   // Must match the Worker's taskQueue
  workflowId: `onboard-${userId}`,  // Unique per workflow — prevents duplicates
  args: [userId, email, name],
});

console.log('Workflow started:', handle.workflowId);

// Wait for the result
const result = await handle.result();
console.log('Workflow completed:', result);

// Or don't wait — fire and forget
// Sending a signal to a running workflow
const handle = client.workflow.getHandle(`order-${orderId}`);
await handle.signal('cancelOrder', 'Customer requested cancellation');

// Querying workflow state
const status = await handle.query('getStatus');

// Terminate a workflow (force stop — no cleanup)
await handle.terminate('Emergency stop');

// Cancel a workflow (graceful — cleanup code runs)
await handle.cancel();
// Next.js API route — trigger a workflow from an HTTP handler
// app/api/onboard/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getClient } from '@/temporal/client';
import type { onboardUserWorkflow } from '@/temporal/workflows';

export async function POST(req: NextRequest) {
  const { userId, email, name } = await req.json();

  const client = await getClient();

  const handle = await client.workflow.start(onboardUserWorkflow, {
    taskQueue: 'main-queue',
    workflowId: `onboard-${userId}`,
    args: [userId, email, name],
  });

  return NextResponse.json({ workflowId: handle.workflowId });
}

Fix 4: Activity Configuration and Retries

// Fine-grained activity options
const { riskyOperation, quickLookup, longProcess } = proxyActivities<typeof activities>({
  // Default options applied to all proxied activities
  startToCloseTimeout: '30 seconds',
});

// Per-activity options using separate proxies
const { sendEmail } = proxyActivities<typeof activities>({
  startToCloseTimeout: '10 seconds',
  retry: {
    initialInterval: '1 second',
    backoffCoefficient: 2,        // Double the wait each retry
    maximumInterval: '30 seconds',
    maximumAttempts: 5,           // Stop after 5 attempts
    nonRetryableErrorTypes: ['InvalidEmail', 'EmailBlocked'],
  },
});

const { processVideo } = proxyActivities<typeof activities>({
  scheduleToCloseTimeout: '1 hour',  // Max total time including retries
  startToCloseTimeout: '55 minutes', // Max time for a single attempt
  heartbeatTimeout: '5 minutes',     // Activity must heartbeat within this window
});
// Heartbeating in long-running activities
import { Context } from '@temporalio/activity';

export async function processLargeFile(fileId: string): Promise<void> {
  const file = await downloadFile(fileId);

  for (let i = 0; i < file.chunks.length; i++) {
    // Check for cancellation
    Context.current().heartbeat({ progress: i / file.chunks.length });

    await processChunk(file.chunks[i]);

    // Yield to allow the runtime to check for cancellation
    if (i % 10 === 0) {
      await new Promise((resolve) => setImmediate(resolve));
    }
  }
}

Fix 5: Workflow Versioning

Non-determinism errors appear when you change workflow code while old instances are still running:

NonDeterminismError: Workflow history does not match workflow definition

Use patched() to safely deploy changes:

import { patched, deprecatePatch } from '@temporalio/workflow';

export async function myWorkflow(): Promise<void> {
  // Before the change (v1):
  // await doOldThing();

  // After the change — use patched() to branch based on workflow history
  if (patched('add-new-step-2024')) {
    // New code path — runs for workflows started after this deploy
    await doNewThing();
  } else {
    // Old code path — runs for workflows that already executed past this point
    await doOldThing();
  }

  await doCommonThing();
}

// Once all old workflow instances are complete, remove the patch:
// 1. Replace patched() with deprecatePatch() (one deploy)
// 2. Remove the else branch entirely (next deploy)

Fix 6: Schedules (Cron-like Workflows)

// Create a schedule — replaces the old cronSchedule option
const client = await getClient();

await client.schedule.create({
  scheduleId: 'daily-report',
  spec: {
    cronExpressions: ['0 9 * * MON-FRI'],  // 9am UTC on weekdays
    // Or use intervals:
    // intervals: [{ every: '1 hour' }],
  },
  action: {
    type: 'startWorkflow',
    workflowType: generateDailyReport,
    taskQueue: 'main-queue',
    args: [{ reportDate: undefined }],  // Dynamic args can be set at trigger time
  },
  policies: {
    overlap: ScheduleOverlapPolicy.SKIP,  // Skip if previous run is still going
    catchupWindow: '1 minute',
  },
  state: {
    paused: false,
  },
});

// Manage schedules
const schedule = client.schedule.getHandle('daily-report');
await schedule.pause('Maintenance window');
await schedule.unpause();
await schedule.trigger();           // Run immediately once
await schedule.delete();

Fix 7: Temporal Cloud Setup

// Connect to Temporal Cloud (instead of self-hosted)
import { Connection, Client } from '@temporalio/client';
import fs from 'fs';

const connection = await Connection.connect({
  address: `${process.env.TEMPORAL_NAMESPACE}.tmprl.cloud:7233`,
  tls: {
    clientCertPair: {
      crt: Buffer.from(process.env.TEMPORAL_TLS_CERT!, 'base64'),
      key: Buffer.from(process.env.TEMPORAL_TLS_KEY!, 'base64'),
    },
  },
});

const client = new Client({
  connection,
  namespace: process.env.TEMPORAL_NAMESPACE,  // e.g. 'mycompany.acct-id'
});
# .env for Temporal Cloud
TEMPORAL_NAMESPACE=mycompany.abcd1234
TEMPORAL_TLS_CERT=base64-encoded-client-cert
TEMPORAL_TLS_KEY=base64-encoded-client-key

Fix 8: Temporal vs AWS Step Functions vs Cadence vs Airflow vs Inngest vs Trigger.dev

Workflow orchestration is a crowded space. The choice depends on your runtime (Node vs polyglot), your deployment model (self-host vs SaaS), and how you express workflows (code vs JSON vs DAG).

Temporal is code-first and language-rich. You write workflows in TypeScript, Go, Java, Python, or .NET, and the same Temporal Server runs them all. Self-host it via Docker or Kubernetes, or use Temporal Cloud. The mental model — durable execution where the runtime replays your code from history — is unique and powerful, but has the steepest learning curve on this list. Pick Temporal when you have long-running workflows (days or weeks), need cross-language orchestration, or are building a platform team that owns workflow infrastructure.

AWS Step Functions is JSON-first and AWS-native. Workflows are defined in Amazon States Language (a JSON DSL), and each step typically invokes a Lambda. Pricing is per state transition ($25 per million). There’s no self-host option — it’s SaaS-only inside AWS. Pick Step Functions when you’re already deep in AWS, your workflows are short-lived (Lambda’s 15-minute cap applies), and you prefer declarative DAGs to imperative code.

Cadence (originally from Uber) is Temporal’s predecessor. Temporal was forked from Cadence in 2019 by Cadence’s original authors. The APIs are similar, but Temporal has moved faster on features (Schedules, Update API, async completion). Cadence is still maintained by Uber and is a reasonable choice if you trust Uber’s release cadence more than Temporal’s commercial roadmap. For most new projects, pick Temporal.

Airflow is DAG-first and Python-native. Workflows are Python files defining directed acyclic graphs of tasks. Airflow excels at scheduled data pipelines (ETL, ML training) but is awkward for event-driven, long-lived business workflows. The scheduler runs DAGs on a clock; it does not naturally handle “wait for user input” or “react to webhook.” Pick Airflow when your workload is batch data engineering, not application orchestration.

Inngest is event-first and TypeScript-first. You write step functions that respond to events, and Inngest handles retries, fan-out, and parallel execution. It’s SaaS-first (with an open-source self-host option) and integrates tightly with Vercel and Next.js. The pricing model is per-step, which fits low-volume workloads cheaply but gets expensive at scale. Pick Inngest when your workflow is “user signs up → trigger five things” and you don’t want infrastructure.

Trigger.dev is similar to Inngest in pitch — TypeScript-first, event-driven, SaaS — but with a more workflow-as-code orientation. v3 introduced a self-hostable runtime called Trigger.dev v3 that runs workflows in isolated containers. Pick Trigger.dev when you want long-running jobs (hours or days) in TypeScript without Temporal’s complexity.

Rule of thumb: Temporal for long-lived, complex, multi-language workflows where you need durability guarantees. Step Functions for AWS-only short workflows. Inngest or Trigger.dev for TypeScript-first event-driven flows where SaaS is acceptable. Airflow for Python data pipelines. BullMQ (a simpler Redis-based queue) for short, stateless jobs.

Still Not Working?

“Connection refused” at localhost:7233 — Temporal Server is not running. Start it with temporal server start-dev or the Docker command above.

Workflows enqueued but never execute — the Worker is not connected to the same Task Queue. Check that taskQueue in Worker.create() exactly matches taskQueue in client.workflow.start(). Also verify the Worker process is running and not crashed.

NonDeterminismError — you changed workflow code while old instances are still running. Use patched() to branch, or let old instances complete before deploying. Never use Date.now(), Math.random(), or direct I/O inside workflow code — those go in activities.

Activities retrying forever — Temporal’s default retry policy has maximumAttempts: unlimited. Set maximumAttempts in the activity options, or make the error non-retryable by throwing an ApplicationFailure.nonRetryable().

Workflow stuck in “Running” — the workflow is waiting on an activity that’s stuck or the Worker is down. Check the Temporal UI (localhost:8233) → Workflows → find the workflow → view the event history to see exactly where it’s blocked.

Worker bundle out of sync with deployed code — the Worker bundles workflow code at startup. If you redeploy the API server with new workflow logic but don’t restart the Worker, old code runs and may emit NonDeterminismError. Ensure your deployment script restarts both the API and Worker processes (or use Worker.runUntil() to recycle on SIGTERM).

Temporal Cloud TLS errors on connect — the gRPC connection to *.tmprl.cloud:7233 requires mTLS with client cert/key. If you see UNAUTHENTICATED or permission denied, double-check that the cert and key are base64-decoded correctly and that the namespace string includes the account ID suffix (e.g., mycompany.abc12, not just mycompany).

Activity argument size exceeded — activity inputs and outputs are persisted to history. Large payloads (over ~2MB) trigger BlobSizeLimitError. The fix: store the large data in S3 (or any blob store) and pass only the object URL through workflow code.

For workflow orchestration alternatives, see Fix: AWS Step Functions Not Working, Fix: Inngest Not Working, Fix: Trigger.dev Not Working, and Fix: BullMQ Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles