Skip to content

Fix: Inngest Not Working — Functions Not Triggering, Steps Failing, or Events Not Received

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Inngest issues — function and event setup, step orchestration, retries and error handling, cron scheduling, concurrency control, fan-out patterns, and local development with the Dev Server.

The Problem

An Inngest function is defined but never runs:

const myFunction = inngest.createFunction(
  { id: 'process-order' },
  { event: 'order/created' },
  async ({ event, step }) => {
    console.log('Processing order', event.data.orderId);
  }
);
// Event is sent but the function never executes

Or steps fail silently and retry forever:

async ({ event, step }) => {
  const result = await step.run('fetch-data', async () => {
    return fetch('/api/data').then(r => r.json());
  });
  // Step fails, retries 4 times, then the function fails
}

Or the Dev Server shows “No functions found”:

npx inngest-cli@latest dev
# Dev Server: http://localhost:8288
# No functions registered

Why This Happens

Inngest is a durable function execution engine. Your application code defines functions, but the actual scheduling, retry logic, and state persistence happen on Inngest’s servers (or the local Dev Server). Your app is essentially a worker — Inngest pushes work to it via HTTP, and your serve handler dispatches that work to the right function. This inverted model is the root cause of most “function not triggering” reports: the function is fine, but Inngest cannot reach your handler, or your handler did not register the function during the last sync.

A few specifics drive the most common failures. Functions must be served via an HTTP endpoint — Inngest calls your functions by sending HTTP requests to your app at /api/inngest. If that route is missing, or the Dev Server cannot reach it (wrong port, firewall, tunnel down), functions are registered in metadata but never invoked. The serve handler also performs registration on every sync, so a function you forgot to add to the functions array is invisible — it does not matter that it is defined elsewhere. Steps are individually retriable units — each step.run() is a separate HTTP round trip, and step return values are memoized so a retry skips already-successful steps.

The last common source of confusion is event names and environments. Event names must match exactly including casing and separators — 'order/created' and 'order.created' are different streams. Environment isolation means events sent with a development key never reach production functions and vice versa, so a missing or wrong INNGEST_EVENT_KEY silently routes traffic to the wrong branch environment. Signing keys are also per-environment and must match between the dashboard and your deployed app.

Platform and Environment Differences

Inngest runs the same logical model everywhere, but the deployment surface varies. On Next.js App Router, the recommended path is a route handler at app/api/inngest/route.ts exporting GET, POST, and PUT from serve(). On Next.js Pages Router, you export the handler from pages/api/inngest.ts using inngest/next — the same package, different export shape. App Router projects that mistakenly use the Pages handler (or vice versa) get a 405 on PUT during sync because the wrong HTTP method handler is exported.

Hosting changes the runtime constraints. On Vercel, the default serverless function timeout is 10 seconds on Hobby and 60 seconds on Pro — long-running steps will be killed mid-execution. Inngest handles this gracefully (the next sync resumes the step), but you must keep individual step.run callbacks under the platform limit. On Cloudflare Workers, you use inngest/cloudflare and must pass the Request and env to the handler manually. CPU time is capped, and step.sleep durations longer than a few seconds will not block the worker — Inngest schedules the resume from its side. On Netlify Functions, use inngest/lambda since Netlify wraps AWS Lambda. The INNGEST_SIGNING_KEY and INNGEST_EVENT_KEY must be set in the deploy environment, not in the local .env.

Local development uses the Inngest Dev Server which pretends to be the cloud orchestrator. It polls your serve endpoint, replays events, and provides a UI at localhost:8288. The Dev Server uses an in-memory store, so function step state resets when you restart it. Between deploys, function step state persists per deployment ID in production — if you rename a step ID or restructure a function mid-flight, in-flight runs may fail because they cannot find the cached step output by the new name.

Fix 1: Set Up Inngest with Next.js

npm install inngest
// src/inngest/client.ts — create the Inngest client
import { Inngest } from 'inngest';

export const inngest = new Inngest({
  id: 'my-app',
  // Optional: type your events for full TypeScript support
});
// src/inngest/functions.ts — define functions
import { inngest } from './client';

// Function triggered by an event
export const processOrder = inngest.createFunction(
  {
    id: 'process-order',
    retries: 3,  // Retry failed steps up to 3 times
  },
  { event: 'order/created' },
  async ({ event, step }) => {
    // Step 1: Validate order
    const order = await step.run('validate-order', async () => {
      const res = await fetch(`https://api.example.com/orders/${event.data.orderId}`);
      if (!res.ok) throw new Error('Order not found');
      return res.json();
    });

    // Step 2: Charge payment
    const payment = await step.run('charge-payment', async () => {
      return processPayment(order.total, order.paymentMethod);
    });

    // Step 3: Send confirmation email
    await step.run('send-confirmation', async () => {
      await sendEmail({
        to: order.email,
        template: 'order-confirmation',
        data: { orderId: order.id, total: order.total },
      });
    });

    return { orderId: order.id, paymentId: payment.id, status: 'completed' };
  },
);

// Cron-triggered function
export const dailyCleanup = inngest.createFunction(
  { id: 'daily-cleanup' },
  { cron: '0 3 * * *' },  // Every day at 3 AM UTC
  async ({ step }) => {
    await step.run('delete-expired-sessions', async () => {
      await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
    });

    await step.run('archive-old-logs', async () => {
      await db.delete(logs).where(lt(logs.createdAt, subDays(new Date(), 30)));
    });
  },
);
// app/api/inngest/route.ts — Next.js App Router
import { serve } from 'inngest/next';
import { inngest } from '@/inngest/client';
import { processOrder, dailyCleanup } from '@/inngest/functions';

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [processOrder, dailyCleanup],
  // All functions must be listed here
});
// Send events from anywhere in your app
import { inngest } from '@/inngest/client';

// In a Server Action, API route, or route handler
export async function createOrder(data: OrderInput) {
  const order = await db.insert(orders).values(data).returning();

  // Trigger the Inngest function
  await inngest.send({
    name: 'order/created',
    data: {
      orderId: order[0].id,
      userId: data.userId,
      total: data.total,
    },
  });

  return order[0];
}

Fix 2: Step Orchestration Patterns

// Wait for an external event (webhook, user action)
export const onboardingFlow = inngest.createFunction(
  { id: 'user-onboarding' },
  { event: 'user/signed-up' },
  async ({ event, step }) => {
    // Send welcome email immediately
    await step.run('send-welcome', async () => {
      await sendEmail({ to: event.data.email, template: 'welcome' });
    });

    // Wait up to 24 hours for user to verify email
    const verifyEvent = await step.waitForEvent('wait-for-verify', {
      event: 'user/email-verified',
      match: 'data.userId',  // Match by userId
      timeout: '24h',
    });

    if (!verifyEvent) {
      // Timed out — send reminder
      await step.run('send-reminder', async () => {
        await sendEmail({ to: event.data.email, template: 'verify-reminder' });
      });
      return { status: 'reminder-sent' };
    }

    // Email verified — continue onboarding
    await step.run('setup-defaults', async () => {
      await createDefaultSettings(event.data.userId);
    });

    return { status: 'onboarding-complete' };
  },
);

// Sleep / delay between steps
export const drip = inngest.createFunction(
  { id: 'drip-campaign' },
  { event: 'user/trial-started' },
  async ({ event, step }) => {
    await step.run('day-0-email', async () => {
      await sendEmail({ to: event.data.email, template: 'trial-welcome' });
    });

    await step.sleep('wait-3-days', '3d');

    await step.run('day-3-email', async () => {
      await sendEmail({ to: event.data.email, template: 'trial-tips' });
    });

    await step.sleep('wait-4-more-days', '4d');

    await step.run('day-7-email', async () => {
      await sendEmail({ to: event.data.email, template: 'trial-ending' });
    });
  },
);

// Parallel steps (fan-out)
export const batchProcess = inngest.createFunction(
  { id: 'batch-process' },
  { event: 'batch/started' },
  async ({ event, step }) => {
    const items = event.data.items as string[];

    // Run steps in parallel
    const results = await Promise.all(
      items.map((item, i) =>
        step.run(`process-item-${i}`, async () => {
          return processItem(item);
        })
      )
    );

    await step.run('aggregate-results', async () => {
      return aggregateResults(results);
    });
  },
);

Fix 3: Error Handling and Retries

import { NonRetriableError } from 'inngest';

export const riskyFunction = inngest.createFunction(
  {
    id: 'risky-operation',
    retries: 5,                 // Max retry attempts per step
    onFailure: async ({ error, event, step }) => {
      // Called after all retries are exhausted
      await step.run('notify-failure', async () => {
        await sendAlert({
          channel: '#errors',
          message: `Function failed: ${error.message}`,
          event: event.data,
        });
      });
    },
  },
  { event: 'risky/started' },
  async ({ event, step }) => {
    await step.run('check-input', async () => {
      if (!event.data.userId) {
        // Non-retriable — don't waste retries on bad input
        throw new NonRetriableError('userId is required');
      }
    });

    await step.run('call-external-api', async () => {
      const res = await fetch('https://api.external.com/process', {
        method: 'POST',
        body: JSON.stringify({ userId: event.data.userId }),
      });

      if (res.status === 400) {
        // Client error — retrying won't help
        throw new NonRetriableError(`Bad request: ${await res.text()}`);
      }

      if (!res.ok) {
        // Server error — retrying might help
        throw new Error(`API returned ${res.status}`);
      }

      return res.json();
    });
  },
);

Fix 4: Concurrency and Throttling

// Limit concurrent executions
export const sendNotification = inngest.createFunction(
  {
    id: 'send-notification',
    concurrency: {
      limit: 5,  // Max 5 concurrent executions
    },
  },
  { event: 'notification/send' },
  async ({ event, step }) => {
    await step.run('send', async () => {
      await pushNotification(event.data.userId, event.data.message);
    });
  },
);

// Throttle by key — limit per user, per resource, etc.
export const apiSync = inngest.createFunction(
  {
    id: 'api-sync',
    throttle: {
      limit: 1,
      period: '1m',
      key: 'event.data.userId',  // 1 sync per user per minute
    },
  },
  { event: 'sync/requested' },
  async ({ event, step }) => {
    await step.run('sync-data', async () => {
      await syncUserData(event.data.userId);
    });
  },
);

// Rate limit with concurrency key
export const processWebhook = inngest.createFunction(
  {
    id: 'process-webhook',
    concurrency: {
      limit: 10,
      key: 'event.data.tenantId',  // 10 concurrent per tenant
    },
  },
  { event: 'webhook/received' },
  async ({ event, step }) => {
    // Each tenant gets up to 10 parallel executions
    await step.run('process', async () => {
      return handleWebhook(event.data);
    });
  },
);

// Debounce — only run once for repeated events
export const updateSearchIndex = inngest.createFunction(
  {
    id: 'update-search-index',
    debounce: {
      period: '10s',
      key: 'event.data.documentId',
    },
  },
  { event: 'document/updated' },
  async ({ event, step }) => {
    // If the document is updated 5 times in 10 seconds,
    // this function only runs once with the latest event
    await step.run('reindex', async () => {
      await reindexDocument(event.data.documentId);
    });
  },
);

Fix 5: Local Development

# Start the Inngest Dev Server
npx inngest-cli@latest dev

# Opens at http://localhost:8288
# Automatically discovers functions at http://localhost:3000/api/inngest
// Custom port or app URL
// npx inngest-cli@latest dev -u http://localhost:5173/api/inngest

// Send test events from the Dev Server UI
// Or programmatically:
import { inngest } from '@/inngest/client';

// In a test file or script
await inngest.send({
  name: 'order/created',
  data: {
    orderId: 'test-123',
    userId: 'user-456',
    total: 99.99,
  },
});

Dev Server shows “No functions found”:

  1. Your app must be running (npm run dev)
  2. The Dev Server must be able to reach your app’s serve endpoint
  3. Check the serve endpoint returns function metadata on GET
  4. Verify the URL — default is http://localhost:3000/api/inngest

Fix 6: Typed Events

// src/inngest/client.ts — full type safety
import { Inngest, EventSchemas } from 'inngest';

// Define event types
type Events = {
  'order/created': {
    data: {
      orderId: string;
      userId: string;
      total: number;
      items: Array<{ productId: string; quantity: number }>;
    };
  };
  'order/shipped': {
    data: {
      orderId: string;
      trackingNumber: string;
      carrier: 'fedex' | 'ups' | 'usps';
    };
  };
  'user/signed-up': {
    data: {
      userId: string;
      email: string;
      plan: 'free' | 'pro' | 'enterprise';
    };
  };
  'user/email-verified': {
    data: {
      userId: string;
    };
  };
};

export const inngest = new Inngest({
  id: 'my-app',
  schemas: new EventSchemas().fromRecord<Events>(),
});

// Now events and function triggers are fully typed
// inngest.send({ name: 'order/created', data: { orderId: '...' } })
//                                              ^^^^^^^^ — TypeScript checks this

Still Not Working?

Function registered but never triggers — the event name must match exactly. inngest.send({ name: 'order/created' }) only triggers functions listening for 'order/created', not 'order.created' or 'Order/Created'. Check the Dev Server’s event stream to see if the event was received.

Steps re-execute after a failure — this is by design. When a step fails and the function retries, previously successful steps are skipped (their return values are memoized). Only the failed step re-runs. If you see all steps re-running, each step might be throwing, or the function is failing before reaching the step that needs retry.

Dev Server can’t find functions — the Dev Server makes a GET request to your serve endpoint to discover functions. If your app is on a non-standard port, pass it explicitly: npx inngest-cli dev -u http://localhost:5173/api/inngest. Also ensure the route handler exports GET, POST, and PUT.

“Event key is required” in production — when deploying to Inngest Cloud, you need an event key set as INNGEST_EVENT_KEY and a signing key as INNGEST_SIGNING_KEY. These are available in the Inngest dashboard after creating an app. Without them, the client can’t authenticate with the Inngest servers.

Functions sync fine in preview but disappear in production — Inngest treats each branch environment as separate. The signing key for main does not authorize the preview branch endpoint. Set per-environment keys in your hosting provider’s dashboard so each deploy syncs to the correct branch environment in Inngest.

Vercel returns 504 on long-running functions — your handler timed out before the step finished. Move the heavy work into smaller step.run callbacks (each step is its own request that resets the timeout), or upgrade to Pro for the 60-second limit. For the Vercel-side debugging steps, see Fix: Vercel Edge Function Not Working.

Step state lost after redeploy — you renamed a step.run ID while a function was mid-flight. In-flight runs look up step output by ID, and the new ID has no cached value. Roll back briefly so in-flight runs drain, then deploy the renamed version. For related Cloudflare Workers deployment pitfalls, see Fix: Wrangler Not Working and Fix: Nitro Not Working. For Next.js handler issues that prevent registration, see Fix: Next.js API Route 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