TypeScript SDK Migration Guide: v3 to v4

This guide helps you migrate your Inngest TypeScript SDK from v3 to v4.

New features

  • Middleware is more powerful and intuitive (more info)
  • Trigger helpers (more info)
  • Separate internal and user-facing logs with the internalLogger option (more info)
  • Use a worker thread in Connect (more info)

Breaking changes

Middleware rewrite

The middleware system was completely rewritten. To see the new API, see the middleware documentation.

This guide covers how to migrate between the v3 and the v4 version of the inngest package.

Default mode is now "cloud"

The default mode is now cloud instead of dev. This prevents accidental production deployments in development mode and aligns with all other Inngest SDKs.

What this means:

  • In cloud mode, a signing key is required (via INNGEST_SIGNING_KEY or the signingKey option)
  • For local development, explicitly set isDev: true on your client or set INNGEST_DEV=1
// Local development
const inngest = new Inngest({ id: "my-app", isDev: true });

// Production (signing key required via env or option)
const inngest = new Inngest({ id: "my-app" });

or

INNGEST_DEV=1 pnpm run dev

You have encountered this issue if your error looks something like:

Error: Inngest error: A signing key is required to run in Cloud mode, but no signing key was found.

To fix this, choose one of the following:
  - For local development, set INNGEST_DEV=1 to use the Dev Server (e.g. INNGEST_DEV=1 npm run dev)
  - For production, set the INNGEST_SIGNING_KEY environment variable

Find your keys at https://app.inngest.com

Event trigger is now in options

In prior versions of the SDK, you specified triggers for events as the second argument of createFunction. We changed it because the triggers make more implicit sense as options, and the case of specifying a function with no triggers required you to send an empty array which we did not love.

For example, the old syntax:

// Old (v3)
inngest.createFunction(
  { id: "fn-id" },
  { event: "fn/trigger-event" },
  async ({ event }) => {
    // ...
  }
)

// New (v4)
inngest.createFunction(
  { id: "fn-id", triggers: { event: "fn/trigger-event" } },
  async ({ event }) => {
    // ...
  }
)

Multiple triggers can be passed as an array, a la:

triggers: [
  { event: "fn/trigger-event" },
  { cron: "1 */2 * * *" }
]

A triggerless function is as simple as not providing a trigger.

Event schemas replaced with event types

The centralized schemas option on the Inngest client (EventSchemas class) has been removed. Instead, use the eventType() function to define event types that are shared between sending events, waiting for events, and event triggers. See the trigger helpers reference for full documentation on eventType(), cron(), invoke(), and staticSchema().

// Old (v3) - centralized type-only schemas on the client
const inngest = new Inngest({
  id: "my-app",
  schemas: new EventSchemas().fromRecord<{
    "user/created": { data: { userId: string; email: string } };
  }>(),
});
inngest.createFunction(
  { id: "on-user-created" },
  { event: "user/created" },
  async ({ event }) => {
    // event.data typed from centralized schema
  }
);

// New (v4) - decentralized event types with optional runtime validation
const inngest = new Inngest({ id: "my-app" });
const userCreated = eventType("user/created", {
  schema: z.object({ userId: z.string(), email: z.string() }),
});
inngest.createFunction(
  { id: "on-user-created", triggers: [userCreated] },
  async ({ event }) => {
    // event.data typed as { userId: string; email: string }
  }
);

Using a runtime schema library (e.g. Zod) will result in a runtime type check. If you don't want runtime type checking, use staticSchema() instead:

  type UserCreatedPayload = { userId: string; email: string };

  const userCreated = eventType("user/created", {
    schema: staticSchema<UserCreatedPayload>(),
  });

Event types can also be used with step.waitForEvent and inngest.send:

// Return value is inferred and validated using `userCreated`'s schema
const user = await step.waitForEvent("wait", {
  event: userCreated,
  timeout: "7d",
});

// Sent data is validated using `userCreated`'s schema
await inngest.send(userCreated.create({ userId: "1", email: "a@b.com" }));

Rename serveHost to serveOrigin

Rename the serveHost option to serveOrigin to better reflect its purpose. Using "host" was actually a misnomer because the scheme and port can be specified, while a "host" is only the domain or IP.

The INNGEST_SERVE_HOST environment variable is still supported for backward compatibility but will log a deprecation warning. Please migrate to INNGEST_SERVE_ORIGIN.

Serve options moved to client

Many of the options previously passed to the serve function were moved up to the client level. These properties make more sense at this level and, because it only involves potentially reorganizing where you're setting values, should be a very straightforward migration.

The options that you may need to reorganize are:

  • baseUrl
  • fetch
  • signingKey
  • signingKeyFallback

If you are passing any of these values to the serve function, or the createServer function, you will need to modify your code so that they are instead provided to the client.

// Old (v3)
const inngest = new Inngest({ id: "my-app" });
app.use(
  "/api/inngest",
  serve({
    client: inngest,
    functions,
    signingKey: "my-signing-key",
    signingKeyFallback: "my-fallback-key",
    baseUrl: "https://my-inngest-instance.example.com",
  })
);

// New (v4)
const inngest = new Inngest({
  id: "my-app",
  signingKey: "my-signing-key",
  signingKeyFallback: "my-fallback-key",
  baseUrl: "https://my-inngest-instance.example.com",
});
app.use("/api/inngest", serve({ client: inngest, functions }));

If you were relying on environment variables (e.g., INNGEST_SIGNING_KEY) rather than passing these options explicitly, no changes are required—the client will automatically read from the environment.

Remove logLevel option

Log level is now purely the responsibility of the logger object passed to the client's logger option.

The default logger level is info. If you'd like to change that, you can manually pass our default logger:

import { ConsoleLogger, Inngest } from "inngest";

export const inngest = new Inngest({
    id: "my-app",
    logger: new ConsoleLogger({ level: "debug" })
});

Simplify streaming option

The streaming option in serve() has been simplified from "allow" | "force" | false to true | false.

  • "force"true (enable streaming; throws error if handler doesn't support it)
  • "allow" → removed (use true instead)
  • falsefalse (unchanged)
// Old (v3)
serve({ client, functions, streaming: "force" });

// New (v4)
serve({ client, functions, streaming: true });

If the serve function does not support streaming then it throws an error. Previously, it silently ignored the option.

Optimized parallelism enabled by default

"Optimized parallelism" significantly reduces the number of requests necessary to run parallel steps (steps in Promise.all, Promise.allSettled, etc.). This decrease in requests can dramatically improve CPU usage, memory usage, and function run duration.

The primary downside is the change in Promise.race behavior. Promise.race will wait for all promises to settle before resolving. The correct "winner" is returned, but the Promise.race will not immediately resolve on the first winner. If you were relying on the early resolution behavior, use the new group.parallel() helper. This will disable optimized parallelism for groups of steps:

// Old behavior (no longer works as expected with optimized parallelism)
const winner = await Promise.race([
  step.run("a", () => "a"),
  step.run("b", () => "b"),
]);

// New approach using group.parallel() from the function context
const winner = await group.parallel(async () => {
  return Promise.race([
    step.run("a", () => "a"),
    step.run("b", () => "b"),
  ]);
});

If you'd like to disable optimized parallelism altogether at either the client or function level, set optimizeParallelism: false.

Checkpointing enabled by default

Checkpointing is now enabled by default for all functions. This means multiple steps can execute within a single request, resulting in dramatically lower latency and bandwidth usage.

To disable checkpointing, set checkpointing: false on your client or on individual functions:

// Disable for all functions
const inngest = new Inngest({ id: "my-app", checkpointing: false });

// Disable per-function
inngest.createFunction(
  { id: "my-fn", checkpointing: false },
  async ({ step }) => {
    // ...
  }
);

Connect: rewriteGatewayEndpoint replaced with gatewayUrl

The rewriteGatewayEndpoint callback option has been removed from connect(). Use the gatewayUrl string option or the INNGEST_CONNECT_GATEWAY_URL environment variable instead.

// Old (v3)
const connection = await connect({
  apps: [...],
  rewriteGatewayEndpoint: (url) => {
    const clusterUrl = new URL(url);
    clusterUrl.host = 'my-cluster-host:8289';
    return clusterUrl.toString();
  },
});

// New (v4)
const connection = await connect({
  apps: [...],
  gatewayUrl: "wss://my-cluster-host:8289/v0/connect",
});

Connect worker thread

Connect internals (e.g. the WebSocket connection) are now in a worker thread. This solves an issue where event loop starvation (e.g. CPU heavy work) blocked heartbeats, tricking the Inngest server into thinking the worker died.

This change shouldn't break any user facing behavior, but it's good to be aware of. If you do notice an issue with Connect, you can move the WebSocket connection back to the main thread by setting isolateExecution: false in your connect() options or by setting the INNGEST_CONNECT_ISOLATE_EXECUTION environment variable to false.

Edge environment improvements

Fetch and configuration are now resolved lazily at first use rather than eagerly at client construction. This means you no longer need to manually bind globalThis.fetch before creating an Inngest client in edge environments (Cloudflare Workers, Vercel Edge, Deno, etc.).

Remove support for string function IDs in step.invoke()

Passing a raw string to step.invoke() is no longer supported. Use referenceFunction() or pass an imported function instance instead.

// Old (v3) - No longer works
await step.invoke("my-step", {
  function: "my-app-other-fn",
  data: { foo: "bar" },
});

// New (v4) - Use referenceFunction for cross-app invocation
await step.invoke("my-step", {
  function: referenceFunction({ appId: "my-app", functionId: "other-fn" }),
  data: { foo: "bar" },
});

// Or pass an imported function instance directly
await step.invoke("my-step", {
  function: otherFn,
  data: { foo: "bar" },
});

The referenceFunction() helper provides type safety and avoids the footgun of manually constructing the appId-functionId string.