# 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](/docs-markdown/reference/typescript/v4/middleware/lifecycle))
- Trigger helpers ([more info](/docs-markdown/reference/typescript/v4/functions/triggers))
- Separate internal and user-facing logs with the `internalLogger` option ([more info](/docs-markdown/reference/typescript/v4/logging#internal-logger))
- Use a worker thread in Connect ([more info](/docs-markdown/reference/typescript/v4/migrations/v3-to-v4#connect-worker-thread))

## Upgrade prompt

You can use your coding agent of choice to upgrade to v4. Here's a suggested prompt:

```plaintext
Upgrade Inngest TypeScript SDK from v3 to v4

Read the migration guide at https://www.inngest.com/docs-markdown/reference/typescript/v4/migrations/v3-to-v4 and apply every breaking change to this codebase. Pay special attention to:

- Triggers moving into the options object (1st arg of createFunction)
- EventSchemas being replaced with eventType() and staticSchema()
- Serve options (signingKey, baseUrl, etc.) moving to the client constructor
- step.invoke() no longer accepting string function IDs
- Set `maxRuntime` option for `checkpointing` if running on serverless.

Install the latest v4 package and verify TypeScript compilation passes afterward.
```

## Breaking changes

### Middleware rewrite

The middleware system was completely rewritten. To see the new API, see the [middleware](/docs-markdown/reference/typescript/v4/middleware/lifecycle) 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`

```typescript
// 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

```sh
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:

```ts
// 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:

```ts
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](/docs-markdown/reference/typescript/v4/functions/triggers) for full documentation on `eventType()`, `cron()`, `invoke()`, and `staticSchema()`.

```ts
// 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:

```ts
  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`:

```ts
// 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" }));
```

> **Tip:** Please note: staticSchema expects a type, not an interface. You may need to convert existing interfaces to types.

### 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.

```typescript
// 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:

```ts
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)
- `false` → `false` (unchanged)

```typescript
// 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:

```typescript
// 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](/docs-markdown/setup/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.

If your functions run on serverless platforms, like Vercel, you should configure the `maxRuntime` option to slightly below your function's maximum duration:

```ts
const inngest = new Inngest({
  id: 'my-app',
  checkpointing: {
    maxRuntime: '50s', // 50s might be a good option if your max duration is 60s
  }
})
```

> **Warning:** Many platforms, like Vercel, allow you to configure the maximum duration per function, e.g. on your /api/inngest endpoint. We recommend setting the maxRuntime to 60-80% of your maximum duration.For Vercel applications, you should explicitly set your maxDuration (docs) on the /api/inngest. For example:import \{ serve } from 'inngest/next';
> import \{ inngest, functions } from '../../inngest';
>
> // This endpoint can run for a maximum of 300 seconds
> export const maxDuration = 300;
>
> export default serve(\{ client: inngest, functions });Learn more about configuring Vercel here.

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

```typescript
// 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.

```typescript
// 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.

```typescript
// 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.