Versioning and Function Evolution
Long-running functions inevitably change over time. Inngest enables developers to deploy changes to functions without explicit version markers or complex migration logic. This guide explains how Inngest handles versioning and the strategies you can use to evolve functions safely.
How Inngest handles versioning
Inngest uses step-based memoization and a graceful deterministic execution model. This is consistent across all language SDKs. Unlike systems that require explicit version annotations in your code, Inngest tracks function state through step identifiers, allowing you to modify functions while they're running.
Step-based memoization
Each step in a function has a unique string identifier. When a function executes, the SDK:
- Hashes the step's identifier along with a counter (enabling steps inside loops)
- Checks if this hash exists in the function's stored state
- If found, returns the memoized result without re-executing
- If not found, executes the step and stores the result
This means that completed steps are never re-executed, even across deployments. The SDK determines what to run based on the step identifiers in your code, not version numbers.
For more details on this execution model, see How Inngest functions are executed.
Graceful determinism by default
The SDK handles changes gracefully by default:
- New steps are executed when discovered — If you add a step to a function, in-progress runs will execute it when they encounter it
- Warnings, not failures — If step execution order changes, the SDK logs a warning rather than failing the function
This approach lets you extend and improve functions over time without worrying about in-progress runs failing.
Evolving functions over time
There are several strategies for evolving functions, depending on the type of change you're making.
Adding new steps
Adding new steps to a function is generally safe. New steps will execute when discovered by in-progress runs. This is useful for adding logging, analytics, notifications, or other additive functionality.
inngest.createFunction(
{ id: "user-signup" },
{ event: "user/created" },
async ({ event, step }) => {
await step.run("send-welcome-email", async () => {
return sendWelcomeEmail(event.data.email);
});
await step.run("sync-to-crm", async () => {
return crm.contacts.create(event.data);
});
await step.run("schedule-followup", async () => {
return scheduleFollowupEmail(event.data.userId, "3 days");
});
}
);
inngest.createFunction(
{ id: "user-signup" },
{ event: "user/created" },
async ({ event, step }) => {
await step.run("send-welcome-email", async () => {
return sendWelcomeEmail(event.data.email);
});
// New step - executes when discovered, even if later steps already completed
await step.run("track-signup-analytics", async () => {
return analytics.track("user_signup_complete", {
userId: event.data.userId,
});
});
await step.run("sync-to-crm", async () => {
return crm.contacts.create(event.data);
});
await step.run("schedule-followup", async () => {
return scheduleFollowupEmail(event.data.userId, "3 days");
});
}
);
When you deploy this change, an in-progress run that has already completed send-welcome-email and sync-to-crm will:
- Skip
send-welcome-email(memoized) - Execute
track-signup-analytics(new step) - Skip
sync-to-crmif already completed (memoized) - Execute
schedule-followup(new step)
New steps must not depend on data from steps that haven't executed yet. In the example above, track-signup-analytics only uses data from the triggering event, so it's safe to add at any point.
Modifying existing steps
Changing step logic with the same ID is safe. If you modify the code inside a step but keep the same step ID, in-progress runs that have already completed that step will use the memoized result. New runs will execute the updated logic.
Changing step IDs forces re-execution. If you need to re-run a step with new logic for in-progress runs, change the step ID:
const score = await step.run("calculate-risk-score", async () => {
return calculateRiskScore(user.profile);
});
// Changed ID means in-progress runs will re-calculate
// even if they already ran "calculate-risk-score"
const score = await step.run("calculate-risk-score-v2", async () => {
return calculateRiskScoreWithNewModel(user.profile);
});
The SDK logs a warning when step execution order changes. This is expected behavior when intentionally forcing re-execution.
Removing steps
When you remove a step, in-progress runs that have already completed it will continue normally. The memoized data for the removed step remains in state but is simply ignored.
Reordering steps
Reordering steps triggers a warning because the execution order differs from the stored state. The SDK handles this gracefully—memoized steps return their stored results regardless of their position in code, and new steps execute when encountered.
Major logic changes
For complete rewrites where the new logic is incompatible with in-progress runs, use the new function pattern with timestamp-based routing.
This pattern uses two functions that subscribe to the same event, with if expressions to route events based on timestamp:
// Handle events BEFORE the cutover timestamp
const CUTOVER_TS = 1704067200000; // Jan 1, 2024 00:00:00 UTC
export const processUploadV1 = inngest.createFunction(
{ id: "process-upload" },
{
event: "file/uploaded",
if: `event.ts < ${CUTOVER_TS}`,
},
async ({ event, step }) => {
// Original logic - continues for in-progress runs
await step.run("process-file", async () => {
return legacyProcessor(event.data.fileId);
});
}
);
// Handle events AFTER the cutover timestamp
const CUTOVER_TS = 1704067200000;
export const processUploadV2 = inngest.createFunction(
{ id: "process-upload-v2" },
{
event: "file/uploaded",
if: `event.ts >= ${CUTOVER_TS}`,
},
async ({ event, step }) => {
// Completely rewritten workflow
const metadata = await step.run("extract-metadata", async () => {
return extractMetadata(event.data.fileId);
});
await step.run("validate-content", async () => {
return validateContent(metadata);
});
await step.run("process-modern", async () => {
return modernProcessor(event.data.fileId, metadata);
});
}
);
This approach ensures:
- In-progress runs complete with the original logic
- New events trigger the updated function
- No data loss or unexpected failures
This creates a new function in your Inngest dashboard. Once all v1 runs complete, you can remove the original function.
Best practices
Step ID naming
Choose step IDs that are:
- Descriptive:
"charge-customer-payment"not"step-1" - Stable: Avoid IDs that encode values that might change
- Unique: Each step in a function needs a distinct ID
// Good - descriptive and stable
await step.run("send-order-confirmation", async () => { ... });
// Avoid - generic and likely to conflict
await step.run("send", async () => { ... });
Testing version changes locally
Use the Inngest Dev Server to test how changes affect in-progress runs:
- Start a function with a
step.sleep()to pause execution - Modify the function code while it's sleeping
- Observe how the function handles changes when it resumes
This helps you understand the impact of changes before deploying to production.
Further reading
- How Inngest functions are executed — Detailed explanation of durable execution
- Inngest steps — Step methods and patterns