Netlify Functions let a site that is otherwise static run server-side code: an API route, a webhook receiver, a form handler, a nightly job. You put a file in netlify/functions, deploy, and it becomes an HTTPS endpoint with no server to manage. That simplicity hides real constraints: a fixed execution limit, a single region, payload ceilings, and no state between invocations. Getting those right is the difference between a function that works in a demo and one that survives traffic.

This article explains the current request model, routing, the three invocation types (synchronous, background and scheduled), the limits, state and cold starts, then builds a webhook pipeline as a worked example. Limits and configuration fields were checked against Netlify's documentation on 2026-10-04; Netlify has changed them before, so check the configuration page before relying on a number. Caching of function responses is covered separately in distributed persistent rendering.

What a Netlify Function is

A Netlify Function is a serverless function deployed alongside your site. Each request starts or reuses an isolated instance, runs your handler, and returns a response; no memory or local disk is guaranteed to survive to the next request. Functions run in one region for the whole site, cmh (US East, Ohio) by default, changeable in the dashboard under Functions settings to regions such as fra, lhr, sin or syd. Node.js versions follow the AWS Lambda runtimes; if your build's version is not a valid runtime, functions fall back to a default chosen by Netlify.

This is different from Netlify Edge Functions, which run in a Deno-based runtime at many edge locations, close to users, with much tighter CPU budgets. Use edge functions for request rewriting, personalisation and lightweight checks; use regular functions for work that talks to a database, calls slow APIs, or needs Node packages. The broader serverless trade-offs are in serverless computing, and edge runtimes are compared in edge compute.

The handler and the context

The current API uses web-standard Request and Response objects. A function module default-exports a handler that receives the request and a Context, and exports a config object that declares routing:

// netlify/functions/order.mts
import type { Config, Context } from "@netlify/functions";

export default async (req: Request, context: Context) => {
  const { id } = context.params;                 // from the path pattern below
  if (req.method !== "GET") return new Response("method not allowed", { status: 405 });

  const order = await fetchOrder(id);            // your data access
  if (!order) return Response.json({ error: "not found" }, { status: 404 });

  context.waitUntil(recordView(id, context.geo?.country, context.requestId));
  return Response.json(order, { headers: { "Cache-Control": "private, no-store" } });
};

export const config: Config = {
  path: "/api/orders/:id",
  method: "GET",
};

The context carries params (path parameters), geo and ip for the client, requestId for log correlation, cookies, site and deploy metadata, and waitUntil(promise), which lets work such as analytics continue after the response is sent without delaying the user. Older functions use the Lambda-style signature, export const handler = async (event, context) => ({ statusCode, body }); both are supported, but the web-standard form supports routing config, streaming and the newer context, so prefer it for new code.

Routing and build configuration

Without a path, a function is reachable at /.netlify/functions/<name>. With one, it is served at that path on your domain, which removes the need for redirect rules. The config also accepts excludedPath to carve out sub-paths, method to restrict HTTP methods, and preferStatic: true, which serves a static file when one exists at the path and runs the function only otherwise; that is useful for catch-all routes over a static site.

Build-level settings live in netlify.toml:

[functions]
  directory = "netlify/functions"
  node_bundler = "esbuild"
  included_files = ["data/*.json"]          # files read at runtime
  external_node_modules = ["sharp"]         # ship as-is, do not bundle

included_files matters more than it looks: a function that reads a template or a JSON file at runtime will fail in production if the bundler did not include it, even though it works under netlify dev.

Limits

These are the documented defaults as of 2026-10-04. Only region and memory are configurable; the time and payload limits are fixed.

SettingDefault or limit
Regioncmh (US East, Ohio), configurable
Memory1024 MB, configurable
Synchronous execution60 seconds
Scheduled execution30 seconds
Background execution15 minutes
Buffered request or response payload6 MB
Streamed response payload20 MB
Background request payload256 KB

Binary bodies are base64-encoded in transit, which adds about a third, so the practical binary upload limit is about 4.5 MB. For large uploads, have the function return a signed URL to object storage and let the browser upload directly. For long responses, stream: a streamed response can exceed the buffered limit and gets the first byte to the user sooner.

Background functions

A background function is declared with background: true in its config (or, in the older convention, a filename ending in -background). The caller immediately receives an empty 202 response; the function then runs for up to 15 minutes. Its return value goes nowhere, so results must be written somewhere: a database, a blob store, a webhook. If it fails, Netlify retries it twice, one minute and then two minutes later. Availability depends on your plan, so confirm it before designing around it.

Two consequences shape the code. The 256 KB payload limit means you pass a reference, an id or a URL, not the data. And retries mean the function may run more than once for the same input, so it must be idempotent: check a status record before acting and write results under a key derived from the input.

Scheduled functions

A scheduled function declares schedule instead of path, using a cron expression evaluated in UTC or a shortcut such as @hourly or @daily. Its request body is JSON containing next_run, the time of the next invocation. It has a 30-second limit and runs only on published deploys, not on deploy previews or branch deploys; you can trigger it manually with "Run now" in the UI or netlify functions:invoke from the CLI.

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

export default async (req: Request) => {
  const { next_run } = await req.json();
  const store = getStore("webhook-events");
  const { blobs } = await store.list({ prefix: "pending/" });
  const cutoff = Date.now() - 60 * 60 * 1000;       // only items stuck for an hour
  let requeued = 0;
  for (const b of blobs) {
    if (requeued >= 50) break;                      // stay well inside 30 s
    const rec = await store.get(b.key, { type: "json" });
    if (!rec || rec.receivedAt > cutoff) continue;  // still in flight
    await fetch(new URL("/api/process", process.env.SITE_BASE_URL), {
      method: "POST",
      headers: { "x-internal-secret": process.env.INTERNAL_SECRET ?? "" },
      body: JSON.stringify({ key: b.key }),
    });
    requeued++;
  }
  console.log(`requeued ${requeued}; next run ${next_run}`);
};

export const config: Config = { schedule: "@hourly" };

The pattern is to keep scheduled functions as dispatchers: find the work, hand each item to a background function, and exit. Anything that might take more than a few seconds per item does not belong in the scheduled function itself. Note the cap of 50 items per run: if a backlog grows faster than the schedule drains it, the function still finishes in time and the backlog becomes visible in your metrics instead of turning into a timeout that processes nothing. Here /api/process is the background function, so each fetch returns 202 almost immediately and the loop costs milliseconds per item. SITE_BASE_URL is an environment variable you set to the site's production URL, so the dispatcher never calls a preview deploy by mistake. Because /api/process is a public URL like any other function, it must reject requests without the shared INTERNAL_SECRET header.

State, connections and cold starts

Functions are stateless. Module-level variables survive only while an instance stays warm, which makes them good for caching a client or configuration and useless for correctness. For durable state there are two families. Netlify Blobs is a key-value store scoped to the site, accessed with getStore(name) and methods such as get, set, setJSON and list; it suits status records, small documents and caches. A database suits everything relational, but connections are the trap: hundreds of short-lived instances each opening a TCP connection will exhaust a Postgres server. Use a connection pooler or a database that offers an HTTP-based driver, and create the client outside the handler so warm instances reuse it.

Cold starts are the other cost of statelessness. The first request to a new instance pays for runtime start-up and module loading. Keep bundles small, avoid heavy imports on paths that do not need them, and do initialisation lazily. For user-facing latency that must be consistently low, cache responses at the CDN or move the logic to an edge function. Compare the same design trade-offs on other platforms in Vercel edge functions.

Worked example: a webhook pipeline

A shop receives payment webhooks. The provider expects a 2xx within a few seconds and retries otherwise; processing an order means calling a CRM, sending email and updating inventory, which can take a minute when the CRM is slow.

Three kinds of Netlify Function in one webhook pipelinePayment providersigned webhookSynchronous function/api/webhook, 60 s limitverify signature, dedupePOSTBackground functionbackground: true, 15 min202 at once, 2 retriesevent id onlyNetlify Blobsevent status by idrecordmark doneDatabase / CRMexternal, pooled accesswriteScheduled function@hourly, 30 s, UTCscan stuckre-triggerPublished deploys only;not previews or branches
Figure 3. A webhook pipeline using all three invocation types. The synchronous function answers fast and stores a record; the background function does the slow work; the scheduled function reconciles anything left behind. State lives outside the functions.

The synchronous /api/webhook function verifies the signature on the raw body, writes pending/<event_id> with a receivedAt timestamp to a blob store if it is not already there (provider retries become no-ops), triggers the background function with only the event id, and returns 200. It finishes in tens of milliseconds, far inside the 60-second limit. The background function reads the event, does the slow work, and moves the record to done/<event_id>. If the CRM is down, the two automatic retries cover short outages; for longer ones, the hourly scheduled function finds records still pending after an hour and re-triggers them. Every step is idempotent because every step checks the record first. When something does go wrong, the request id logged by the webhook function and the event id in the blob key are enough to trace one payment end to end.

Failure modes

  • Timeouts on slow dependencies. A synchronous function waiting on a slow API hits the 60-second limit; move slow work to a background function.
  • Missing files in production. Runtime reads of files not listed in included_files.
  • Duplicate processing. Background retries and provider retries both re-run work that is not idempotent.
  • Connection exhaustion. Each instance opens its own database connection during a traffic spike.
  • Schedules that never run. Testing on a deploy preview, where schedules do not fire, or forgetting that cron is UTC.
  • Oversized payloads. Uploads beyond about 4.5 MB of binary, or background payloads beyond 256 KB.
  • Far-away region. Default cmh while your database sits in Europe; every query crosses the Atlantic.
  • Secrets in the bundle. API keys hard-coded or imported from a committed file end up in the deployed artefact; keep them in environment variables scoped to functions.
  • Leaking errors. Returning a raw exception message in a 500 response exposes stack traces and internal hostnames; log the detail with the request id and return a generic message.

Trade-offs

Netlify Functions trade control for convenience. You get deploys tied to Git, preview URLs and no infrastructure, but a fixed execution limit, one region per site and limited tuning. Background functions extend the time budget to 15 minutes without a queue service, at the cost of fire-and-forget semantics and only two retries; a real queue gives you visibility and dead-letter handling. Scheduled functions replace a cron server but cap work at 30 seconds. For long-running or high-throughput back ends, a container platform or a cloud provider's function service such as Azure Functions gives more control.

What to do next

  1. Move any Lambda-style handlers you are still editing to the web-standard (req, context) signature with a config.path.
  2. Set the functions region next to your database.
  3. List every external call in each synchronous function and move anything that can exceed a few seconds to a background function.
  4. Make every background function idempotent with a status record keyed by input.
  5. Add a scheduled reconciler for work left pending, and test it with netlify functions:invoke.
  6. Audit runtime file reads against included_files, and database access against a pooler.
  7. Log context.requestId on every line and alert on 5xx rates and execution time near the limits.
Key takeaway: Netlify Functions are stateless handlers with fixed limits: 60 seconds synchronous, 15 minutes in the background, 30 seconds scheduled, 6 MB buffered payloads and one region per site. Answer fast, push slow work to idempotent background functions, reconcile with a schedule, keep state in a store outside the function, and put the region next to your data.