Skip to content

Background Functions on Netlify for Long AI Jobs

10 min read · updated August 11, 2026

A background function on Netlify runs for up to fifteen minutes instead of sixty seconds. It buys that by giving up the response: the caller gets a 202 the instant the invocation is accepted and never sees what your code returned. Everything difficult about this pattern is on the other side of that trade.

What you are trading away

Netlify documents the background function contract precisely: the triggering request receives an immediate 202 success response indicating that the function was successfully invoked, and the function then runs separately. Your return value goes nowhere. Neither does a thrown error, as far as the caller is concerned.

So a background function is the right shape for work whose output is a side effect — a row written, a file stored, a webhook fired, an email queued — and the wrong shape for a chat turn. If a human is waiting for the text, you want a streamed response instead, which keeps the connection and shows progress.

Netlify lists background functions as available on the credit-based Free, Personal and Pro plans and on Enterprise plans. The 15-minute execution limit is documented on the same page and is not configurable.

Declaring one

There are two ways, and Netlify now recommends the first. Set background: true in the function’s exported config:

// netlify/functions/summarise.mts
import type { Config } from "@netlify/functions";

export default async (req: Request) => {
  // ... long work
};

export const config: Config = {
  background: true,
  path: "/api/summarise",
};

The older mechanism is a filename suffix — netlify/functions/summarise-background.mts, or a directory named summarise-background containing index.mts. Netlify states that this is still supported but that new functions should prefer config.background, on the grounds that it is easier to read, easier to toggle and easier to discover. It is also considerably harder to break: a rename that drops the suffix silently converts a 15-minute job into a 60-second one, and the only symptom is a timeout under load.

Building the job

  1. Have the caller supply a job id, or mint one and return it before dispatching. Because the background invocation itself cannot tell the caller anything, the id has to exist on the request. The simplest correct arrangement is a small synchronous endpoint that creates the id, records a pending record, calls the background function and returns the id.
  2. Write a pending record before any slow work. If the process dies at minute fourteen, the difference between a job that is visibly stuck and a job that never existed is this write.
  3. Do the model call with its own deadline, well inside the 15 minutes, so that a hung provider produces a recorded failure rather than a silent termination.
  4. Write the result and the terminal status in one operation so a reader never sees a half-written job.

Netlify Blobs is the least-effort store for this, since it needs no provisioning. The API is getStore from the @netlify/blobs package, with setJSON and a get that takes a type option:

// netlify/functions/summarise.mts
import type { Config } from "@netlify/functions";
import { getStore } from "@netlify/blobs";

export default async (req: Request) => {
  const { jobId, document } = await req.json();
  const store = getStore("summaries");

  await store.setJSON(jobId, { status: "running", startedAt: Date.now() });

  try {
    const upstream = await fetch("https://api.openai.com/v1/responses", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer " + process.env.OPENAI_API_KEY,
      },
      body: JSON.stringify({ model: "gpt-4o-mini", input: document }),
      signal: AbortSignal.timeout(600_000),
    });

    if (!upstream.ok) {
      const detail = await upstream.text();
      await store.setJSON(jobId, {
        status: "failed",
        code: upstream.status,
        detail: detail.slice(0, 2000),
      });
      return;
    }

    const result = await upstream.json();
    await store.setJSON(jobId, { status: "done", result });
  } catch (err) {
    await store.setJSON(jobId, {
      status: "failed",
      detail: err instanceof Error ? err.message : String(err),
    });
  }
};

export const config: Config = {
  background: true,
  path: "/api/summarise",
};

Note that every exit path writes a terminal record. A background function that throws produces no client-visible signal whatsoever, so an unhandled rejection here is a job that stays running forever from the outside.

Collecting the result

The polling endpoint is an ordinary synchronous function. Keep it trivial — it will be called far more often than the job runs:

// netlify/functions/summary-status.mts
import type { Config } from "@netlify/functions";
import { getStore } from "@netlify/blobs";

export default async (req: Request) => {
  const jobId = new URL(req.url).searchParams.get("id");
  if (!jobId) return new Response("missing id", { status: 400 });

  const store = getStore("summaries");
  const record = await store.get(jobId, { type: "json" });
  if (!record) return new Response("not found", { status: 404 });

  return Response.json(record);
};

export const config: Config = { path: "/api/summary-status" };

store.get returns null when the key does not exist, which is why the 404 above is a real branch rather than defensive code — it is the state between the caller receiving its id and the background function’s first write landing.

Three decisions about this endpoint are worth making deliberately rather than discovering later.

  • The status set has to be closed. running, done and failed are enough, but the client needs to treat anything it does not recognise as non-terminal — otherwise adding a fourth state later strands every deployed client on a poll loop that never ends.
  • Poll with backoff, not on a fixed interval. A job that takes eight minutes polled every second is roughly five hundred function invocations to learn nothing, and each one is billable. Start at a second and back off toward ten; the user is not watching the first second of a fifteen-minute job.
  • Decide when records expire. Nothing removes them. A per-job blob written on every run accumulates indefinitely, and the store becomes a slowly growing copy of every model response you have ever produced — which is a data-retention question as much as a storage one. Delete on successful collection, or stamp each record and sweep old ones from the scheduled job you probably already have.

The limits that bite

  • 256 KB request and response payload. Netlify documents this specifically for background functions, against 6 MB for buffered synchronous ones. It is a twenty-four-fold reduction and it is the one nobody expects. Do not post a document into a background function — store it first and pass a key.
  • Fifteen minutes is a ceiling, not a promise. Nothing checkpoints for you. A job at minute fourteen that has not written anything loses everything. Write partial progress if the work has natural segments.
  • No delivery guarantee to reason from. Treat a background invocation as at-least-once and make the work idempotent, keyed on the job id, so a duplicate dispatch overwrites rather than double-charges you for a second model call.
  • Timeouts still look like nothing. A background function terminated at the 15-minute limit produces no response for anyone to observe. This is the argument for the running record carrying a startedAt: a reader can then decide that a job started twenty minutes ago is dead, which the platform will not tell it.

When this is the wrong tool

The 15-minute ceiling makes background functions look like the general answer to slow work on Netlify. They are not, and three shapes of job are better served elsewhere.

  • Anything a human is waiting for. The whole apparatus above — job ids, a store, a polling endpoint, expiry — exists only because the response was given away. If the user is watching, a streamed response keeps the connection, shows progress and needs none of it. Netlify documents streaming functions at the same 60-second execution limit with a 20 MB response cap, and sixty visible seconds beats fifteen invisible minutes for anything conversational. See the edge streaming page.
  • Work that does not fit in fifteen minutes either. A thousand documents through a model is not a long job, it is a thousand short ones. Dispatch one background invocation per unit rather than looping inside one, so a failure costs you one item instead of the batch and so the work scales past the ceiling instead of approaching it. The dispatcher half of that pattern is on the scheduled functions page.
  • Anything needing a durable retry. There is no platform-level retry to configure and no dead-letter destination to inspect. If a job must eventually succeed, the retry has to be something you own — a record whose failed status a later scheduled run picks up — and at that point the queue is the real system and the background function is just its worker.