Skip to content

An agent runpast the time limit.

A run that takes ten minutes doesn't fit in a function that is cut off at five. This guide splits the run into short invocations that each pick up where the last one stopped, and shows what happens when one is killed mid-step.

What the limit takes with it

Serverless platforms end a function that runs too long. On Vercel, a function that doesn't finish in time returns a 504 FUNCTION_INVOCATION_TIMEOUT. The limits in Vercel's functions docs (opens in a new tab) (page dated Aug 24 2026, with fluid compute) were:

Plan, Default, Maximum
PlanDefaultMaximum
Hobby300 seconds300 seconds
Pro and Enterprise300 seconds800 seconds, and 1,800 seconds in beta

Other platforms have their own ceilings. AWS Lambda's standard function timeout is 15 minutes, and Lambda Managed Instances functions can run up to 90 minutes on some asynchronous and event-source invocations. An agent that reads documents, calls tools and waits on slow APIs can pass any of them.

What you lose depends on where the run lives. If the message list, the step counter and the plan sit in the function's memory, the timeout takes all of it. Anything the agent already did outside, such as a sent email, stays done, and nothing records that it was done. A rerun from the start repeats it.

Keep the run outside the function

The fix is to treat one invocation as a slice of a longer run. Everything the run needs to continue goes into a database or a file keyed by a run id: the messages so far, the step number and the results of finished tool calls. An invocation loads that, does some steps, saves after each one, and exits. The next invocation loads the same record.

Save after every step, and write in a way that can't leave half a file. A rename of a finished temp file does it on a disk, and a single transaction does it in a database. The resume guide covers what to put in that record and in what order.

Stop before the limit

Don't wait for the platform to cut you off. Give each invocation a budget a little under the limit, and before starting a step, check whether it would fit. A step you start and can't finish is wasted, and if it was a tool call it may have changed something.

slices.ts

import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";

export type Run = { next: number; done: boolean };
const load = (file: string): Run => (existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { next: 0, done: false });
const save = (file: string, run: Run) => { writeFileSync(file + ".tmp", JSON.stringify(run)); renameSync(file + ".tmp", file); }; // never a half-written file

/**
 * One invocation's worth of work. Does steps from where the saved run left off, and stops before the next
 * step would run past the deadline. Returns "more" when the caller should start another invocation.
 */
export async function runSlice(file: string, totalSteps: number, budgetMs: number, expectedStepMs: number, step: (n: number, key: string) => Promise<void>): Promise<"done" | "more"> {
  const deadline = Date.now() + budgetMs;
  const run = load(file);
  while (run.next < totalSteps) {
    if (Date.now() + expectedStepMs > deadline) return "more"; // leave early, with margin, instead of being cut off mid-step
    await step(run.next, `run-1:${run.next}`); // the key is the same on every attempt at this step
    run.next++;
    save(file, run); // progress is on disk before the next step starts
  }
  run.done = true;
  save(file, run);
  return "done";
}

Two details carry the weight. The deadline check happens before the step, using how long a step usually takes, so the invocation leaves early with room to spare. And the key passed to the step, run-1:3 for the fourth step, is the same on every attempt, so an outside service can recognize a repeat.

For a model call, whose length you can't predict, also pass an abort signal built from the time left, such as AbortSignal.timeout(msLeft). A call that is aborted saves nothing, and the next invocation makes it again. That costs one model call and no side effects.

Start the next invocation

When an invocation ends with work left, something has to start another. The options, from least to most machinery:

  • Call your own endpoint at the end of the invocation, with the run id and a secret so strangers can't start runs. It is easy, and a failed call is lost if you do nothing else about it.
  • Put a message on a queue. The queue retries delivery, which is what you want here, and it delivers at least once, so two invocations for the same run can overlap.
  • Run a scheduled sweep that looks for runs marked unfinished and idle for a minute. It is slower and catches everything the other two drop. Many setups use it as a backstop.

Whichever you pick, assume that two invocations can start for one run. A lease with a fencing token, as in the guide on two workers, keeps the second one out.

Run it, and kill it

The driver below pretends each invocation is a function with a one-second limit and each step takes 300 ms. Eight steps need four invocations. On the first pass it kills the process right after step 4's action, before progress is saved, which is what a timeout in the wrong place looks like. Run it in a loop until the run is done, with node driver.ts reset first:

driver.ts (uses slices.ts from above)

import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
import { runSlice } from "./slices.ts";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const effects = "effects.log"; // a stand-in for the outside world: one line per action that took effect, deduplicated by key
const landed = (key: string) => existsSync(effects) && readFileSync(effects, "utf8").split("\n").includes(key);

// Pretend each invocation is a function with a 1-second limit (a real one has 300 seconds or more) and each step takes 300 ms.
async function step(n: number, key: string) {
  await sleep(300);
  if (landed(key)) console.log(`  step ${n}: the outside world already has ${key}, nothing to do`);
  else { appendFileSync(effects, key + "\n"); console.log(`  step ${n}: sent (${key})`); }
  if (process.env.KILL_AT === String(n) && !existsSync("killed")) {
    appendFileSync("killed", "x");
    process.kill(process.pid, "SIGKILL"); // dies after the action, before progress is saved
  }
}

const state = "run.json";
if (process.argv[2] === "reset") { for (const f of [state, effects, "killed"]) rmSync(f, { force: true }); process.exit(0); }
console.log("invocation starts");
const out = await runSlice(state, 8, 1000, 300, step);
console.log(`invocation ends: ${out}`);
if (out === "done") console.log(`actions on file: ${readFileSync(effects, "utf8").trim().split("\n").length}`);
KILL_AT=4 node driver.ts, run until done
invocation starts
  step 0: sent (run-1:0)
  step 1: sent (run-1:1)
  step 2: sent (run-1:2)
invocation ends: more
invocation starts
  step 3: sent (run-1:3)
  step 4: sent (run-1:4)
Killed
invocation starts
  step 4: the outside world already has run-1:4, nothing to do
  step 5: sent (run-1:5)
  step 6: sent (run-1:6)
invocation ends: more
invocation starts
  step 7: sent (run-1:7)
invocation ends: done
actions on file: 8

The first invocation did three steps and left on its own. The second was killed after step 4 had taken effect. The third began at step 4, found that the outside world already had its key and did not send again, then went on. Eight actions are on file for eight steps.

The step that gets cut off

The kill above lands in the one place no scheme closes: after the action and before the record. The next invocation can't tell whether step 4 ran, so it runs it again, and the key is what makes the repeat harmless. The stand-in in the script checks a log for the key. A real service does the same with an idempotency header, and the retry guide covers the services that don't.

So a step can run more than once, and its effect should land once, provided the receiver recognizes the key. That is the most this design promises.

If you'd rather not build this

Vercel's limits page points to Vercel Workflows (opens in a new tab) for work that needs more time: code that can pause, resume and keep its state for minutes or months. The AI SDK's WorkflowAgent is built to run an agent on it. Inngest, Temporal, Restate and DBOS split a run into recorded steps in a similar way. Argentic is one more option: it journals each tool call and model call, so a restarted run continues from the journal, as the crash recovery page describes. Each of these still needs a key at the receiving service for the step that gets cut off.

What this doesn't cover

  • The steps in the script are sleeps and the outside world is a text file. It shows the control flow, and says nothing about your model latency or how long your steps take.
  • The platform limits are quoted from one docs page on one date. They change, and some need configuration: values above 800 seconds are in beta and need function-level settings. Check the page before you plan around a number.
  • The script runs one invocation at a time. Overlapping invocations are your job to prevent, with a lease.
  • A run that waits on a person or a webhook for hours should not hold a function open at all. Save, exit, and start the next slice when the answer arrives.