Skip to content

Your Function Outlived Its Host

You wrote an eval job. It scores 10,000 rows against a model, one call each. It takes about forty minutes.

Your platform kills it at fifteen. Or at sixty seconds, if it’s a Vercel Pro function. That isn’t a bug — an HTTP request started your job, and your job outlived the request.

Here’s the part people expect to go badly, and it doesn’t. Ironflow notices the dead connection, marks the run WAITING, and schedules a retry — the same path any HTTP failure takes, because from the engine’s side a platform kill and a crashed endpoint look identical.

On the next attempt, every step that already finished replays from the record instead of running again. Your 3,700 scored rows are not re-scored. You don’t pay the model twice. You get run.updated when it reschedules and run.failed if it ultimately gives up, so nothing happens silently.

So the work is safe. The clock still isn’t.

Push versus pull when a job outlives the host's timeout — in push mode Ironflow POSTs to your function on Vercel or Lambda, the host cuts the connection at 15 minutes, and although Ironflow retries and replays completed steps, every attempt gets the same 15-minute window until retries are exhausted and the run fails; in pull mode your worker dials out to Ironflow, so there is no inbound request to time out and the job completes in one attempt

Every attempt gets the same fifteen minutes, because the limit belongs to your host, not to your run. And replay isn’t free — attempt two spends part of its window catching up on attempt one’s work before it can do anything new. Attempt three spends more. The productive slice shrinks each time, and after three attempts (the default maxAttempts) the run fails with the job unfinished.

That’s the trap: durability inside the handler cannot buy you time outside it.

So you do what everyone does. You chunk it. Ten thousand rows becomes two hundred batches of fifty, each its own invocation, each safely inside the window. Now you need a queue, a cursor, a fan-in step to know when all two hundred landed, and a story for the batch that failed while its neighbours succeeded.

You didn’t want a distributed system. You wanted a for-loop.

Pull mode inverts the direction. Your worker dials out to Ironflow and asks for work. There’s no inbound request, so there’s no inbound timeout, and nothing between Ironflow and your code holding a stopwatch.

The function doesn’t change. Here is the TypeScript version:

import { createFunction } from "@ironflow/node";
export const scoreEvalSet = createFunction(
{
id: "score-eval-set",
triggers: [{ event: "eval.batch.requested" }],
mode: "pull",
},
async ({ event, step }) => {
const { setId } = event.data as { setId: string };
const rows = await step.run("load-rows", () => loadRows(setId));
const scores: number[] = [];
for (const [i, row] of rows.entries()) {
scores.push(await step.run(`score-${i}`, () => scoreRow(row)));
}
return step.run("save-scores", () => saveScores(setId, scores));
},
);

Under push, that function is hosted by a route handler on someone else’s clock:

app/api/ironflow/route.ts
import { serve } from "@ironflow/node";
export const POST = serve({
functions: [scoreEvalSet],
signingKey: process.env.IRONFLOW_SIGNING_KEY,
});

Under pull, it’s hosted by a process you own:

worker.ts
import { createWorker } from "@ironflow/node";
const worker = createWorker({
serverUrl: process.env.IRONFLOW_SERVER_URL,
functions: [scoreEvalSet],
maxConcurrentJobs: 4,
});
await worker.start();
process.on("SIGTERM", () => worker.drain());

The same function and hosting choice are available in Go:

type EvalBatch struct {
SetID string `json:"setId"`
}
var ScoreEvalSet = ironflow.CreateFunction(
ironflow.FunctionConfig{
ID: "score-eval-set",
Triggers: []ironflow.Trigger{{Event: "eval.batch.requested"}},
Mode: ironflow.PullMode, // use PushMode with Serve below
},
func(ctx ironflow.Context) (any, error) {
var batch EvalBatch
if err := ctx.Event.Data(&batch); err != nil {
return nil, err
}
rows, err := ironflow.Run(ctx, "load-rows", func() ([]Row, error) {
return loadRows(batch.SetID)
})
if err != nil {
return nil, err
}
scores := make([]Score, 0, len(rows))
for i, row := range rows {
score, err := ironflow.Run(ctx, fmt.Sprintf("score-%d", i), func() (Score, error) {
return scoreRow(row)
})
if err != nil {
return nil, err
}
scores = append(scores, score)
}
return ironflow.Run(ctx, "save-scores", func() (any, error) {
return saveScores(batch.SetID, scores)
})
},
)

With Mode: ironflow.PushMode, host it behind an HTTP route:

handler := ironflow.Serve(ironflow.ServeConfig{
Functions: []ironflow.Function{ScoreEvalSet},
SigningKey: os.Getenv("IRONFLOW_SIGNING_KEY"),
})
http.Handle("/api/ironflow", handler)
log.Fatal(http.ListenAndServe(":3000", nil))

With Mode: ironflow.PullMode, run it in a worker:

worker := ironflow.NewWorker(ironflow.WorkerConfig{
ServerURL: os.Getenv("IRONFLOW_SERVER_URL"),
Functions: []ironflow.Function{ScoreEvalSet},
MaxConcurrentJobs: 4,
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
worker.Drain()
}()
log.Fatal(worker.Run(ctx))

The handler body is identical. One field on the config changed (mode: "pull") and the host changed. The chunking, the queue, the cursor, the fan-in — none of it ever gets written.

Two things come along for free once the worker owns the process: it dials out, so it can sit inside your VPC with no public endpoint, and it stays warm, so a preloaded model or a connection pool survives between jobs instead of cold-starting every time.

A worker is a process, and a process is yours to babysit. You deploy it, watch it, restart it, and pay for it while it idles. Push mode hands all of that to your platform, and for work that fits comfortably inside a request that’s a genuinely better trade — scale-to-zero, no capacity planning, nothing to page you about at 3am.

The line isn’t “long jobs are better.” It’s: if the work fits inside the request, let the request hold it. If it doesn’t, stop pretending it does.

Terminal window
brew install sahina/tap/ironflow

Choose a Tier-1 scaffold.

Terminal window
ironflow init my-app && cd my-app
ironflow serve --dev
pnpm dev
Terminal window
ironflow init my-app --template go-quickstart && cd my-app
ironflow serve --dev
go run main.go

The execution modes guide covers the rest — worker labels for routing, maxConcurrentJobs tuning, and the ConnectRPC streaming transport if polling latency matters to you.

Your function was never the problem. Its host was.