Past the serverless time limit: cron enqueues, a worker does the long job

Your cron function has a wall-clock ceiling; the cleanup doesn't. Enqueue in 200ms, run the real work in a worker, and size the visibility timeout to the job.

When a scheduled function hits its execution ceiling — 15 minutes on the longest Vercel plans, less on most, and 900 seconds on plenty of other platforms — the answer isn’t a bigger function. It’s to stop doing the work in the trigger. The cron handler publishes a message to an Infrai queue and returns in about 200 milliseconds; a worker process with no wall clock consumes it and takes as long as the job actually takes.

Same key, two POST calls, and the scheduled function stops being the thing that has to finish.

Why a bigger runtime is the wrong fix

A 20-minute nightly cleanup that runs inside the trigger has no way to resume. The platform kills it at the ceiling, you get a partial result, and the retry starts from row one. Raising the ceiling buys you a longer version of the same fragility, because the failure mode was never the duration — it was that progress lived only in the process’s memory.

Once the work is a message, a crash is just a redelivery.

Where the long job runsPractical duration capSurvives a crash?What you operate
Inside the cron functionPlatform limit — see Vercel’s function limitsNoNothing
Queue message, container workerNone — bounded by the visibility timeout you setYes, redeliveredOne long-lived process
BullMQ workerNoneYesRedis plus the worker
Temporal workflowNone, with durable step stateYes, per stepA Temporal cluster or its cloud

The cron handler

Whatever triggers it — a platform cron, node-cron in a small always-on process, a GitHub Actions schedule, or Infrai’s own cron namespace on the same key — the handler’s body is the same. Enumerate the units of work, publish one message each, return.

import process from "node:process";
import express from "express";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const CRON_SECRET = process.env.CRON_SECRET ?? "";
const QUEUE = "nightly-cleanup";

const app = express();

async function enqueue(task) {
  const res = await fetch(`${BASE}/v1/queue/publish`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ queue: QUEUE, body: task }),
  });
  const out = await res.json();
  if (!res.ok || out.ok === false) throw new Error(`publish failed: ${out.error?.code ?? res.status}`);
  return out.data.message_id;
}

app.post("/cron/nightly-cleanup", async (req, res) => {
  if (req.get("authorization") !== `Bearer ${CRON_SECRET}`) return res.status(401).json({ error: "unauthorized" });
  const runDate = new Date().toISOString().slice(0, 10);
  const shards = ["a-f", "g-m", "n-s", "t-z"];
  try {
    const ids = await Promise.all(shards.map((shard) => enqueue({ task: "purge_expired_uploads", shard, run_date: runDate })));
    res.status(202).json({ enqueued: ids.length, run_date: runDate });
  } catch (err) {
    console.error(`cron enqueue failed: ${err.message}`);
    res.status(500).json({ error: "enqueue failed" });
  }
});

app.listen(3000, () => console.log("cron receiver listening on :3000"));

Four publishes in parallel finish well inside a second. The handler returns 202 and the platform records a successful run — which matters, because a cron monitor that alerts on failures should be alerting on “we couldn’t enqueue”, not on “the cleanup is still going”.

The shard list is doing quiet work there too. Splitting by key range means four workers can drain the night’s cleanup concurrently, and a shard that fails retries alone.

Size the visibility timeout to the job

This is the part people get wrong, and it produces the worst class of bug: duplicated work that looks like a race condition.

A consumed message is invisible for the queue’s visibility timeout, which defaults to 300 seconds. If your cleanup shard takes eleven minutes, the message becomes visible again at minute five while your worker is still chewing on it, a second worker picks it up, and now two processes are deleting the same rows. There’s no heartbeat call to extend a lease mid-job — that’s a real limitation of this API — so you have exactly two options: make the timeout longer than the job’s worst case, or make the job shorter than the timeout.

Raising it is one call, and it sticks:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X PATCH "https://api.infrai.cc/v1/queue/update/nightly-cleanup" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"visibility_timeout_default":1800}'

Confirm what the queue thinks:

curl -sS "https://api.infrai.cc/v1/queue/get/nightly-cleanup" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "name": "nightly-cleanup",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 1800,
    "max_receive_count": 3,
    "dlq_name": "nightly-cleanup.dlq"
  }
}

Half an hour of lease for a job whose p99 is eleven minutes gives you room, at the cost of a slower recovery when a worker really does die — the message won’t reappear for thirty minutes. That’s the trade-off, and it’s why chunking is usually the better instinct: a shard that processes 5,000 rows in 90 seconds and republishes a continuation cursor keeps both numbers small.

The worker

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
const QUEUE = "nightly-cleanup";

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

async function purgeShard(shard, runDate) {
  const started = Date.now();
  let removed = 0;
  for (let page = 0; page < 40; page++) {
    await sleep(50);
    removed += 250;
  }
  console.log(`shard ${shard} for ${runDate}: removed ${removed} rows in ${Date.now() - started}ms`);
  return removed;
}

let running = true;
process.on("SIGTERM", () => { running = false; });

while (running) {
  const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  if (items.length === 0) { await sleep(5000); continue; }
  for (const msg of items) {
    const { shard, run_date: runDate } = msg.payload;
    try {
      await purgeShard(shard, runDate);
      const { acked } = await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
      if (!acked) console.warn(`shard ${shard} finished but the lease had expired — raise visibility_timeout_default`);
    } catch (err) {
      console.error(`shard ${shard} failed on delivery ${msg.delivery_count}: ${err.message}`);
    }
  }
}

That acked === false branch is your early-warning system. A successful job whose ack is refused means the lease expired mid-run, and it’s reported as QUEUE_MESSAGE_NOT_IN_FLIGHT when you look it up — log it loudly rather than swallowing it, because it’s the signal that duplicate processing has already started.

There’s no long-polling on consume, incidentally. An empty poll comes back in about 50 milliseconds, so sleep between polls or you’ll spend the night hammering a free endpoint.

Confirm the night’s run drained

curl -sS "https://api.infrai.cc/v1/queue/stats/nightly-cleanup" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Zero across available_count, in_flight_count and dlq_count by morning means the run completed. An oldest_message_age_seconds in the thousands means a worker died and nobody replaced it.

What it costs to move the work

Publishing is the only metered call: $0.00002 per message, verified 2026-07-26. Four shards a night is $0.00008 a month in publishes, which is not a number worth optimising. Consume, ack, stats, update and DLQ reads are free and rate-limited. New accounts start with $2 of free credit.

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.id | startswith("queue.")) | {id, billable: .billing.is_billable}]'

Prices move downward over time and discounts run, so read the live figure. The real cost is the worker you now have to run somewhere — a container that costs more per month than the queue does per year.

Where a scheduler library is the better answer

BullMQ’s worker model gives you concurrency control, rate limiting and repeatable job definitions in the same process, which is less moving parts if you already have Redis and don’t need the trigger to be an HTTP call. QStash is the closest thing to a drop-in for the trigger half if you want the schedule itself managed and delivered as a webhook. And if your nightly job is really a five-step pipeline with compensation logic, Temporal models that properly and a queue doesn’t.

What this arrangement doesn’t give you: no priorities, ten messages per consume, three delivery attempts before dead-lettering, and no lease extension. If any of those is a hard requirement, you’d be better off with a job framework. If what you need is “the cron function must return quickly and the work must survive”, two HTTP calls is a remarkably small amount of machinery.

References

Browse more queue developer guides