Skip to content

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

FixDevs ·

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. Functions are triggered by events and can run multi-step workflows that survive failures:

  • Functions must be served via an HTTP endpoint — Inngest calls your functions by sending HTTP requests to your app. If the /api/inngest route isn’t set up, or the Dev Server can’t reach your app, functions are registered but never invoked.
  • The serve handler registers functions with Inngest — you must call serve() with your Inngest client and function list, and expose it as an API route. This handler both registers functions (on sync) and receives execution requests.
  • Steps are individually retriable units — each step.run() call is its own HTTP request. If a step throws, only that step retries (not the whole function). A step’s return value is memoized — after a successful run, it won’t re-execute on subsequent retries.
  • Events must match the trigger exactly — the event field in createFunction’s trigger must match the name passed to inngest.send(). A typo means the event fires but no function listens for it.

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.

For related serverless issues, see Fix: Wrangler Not Working and Fix: Nitro 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