Fixing Cold Starts in Netlify Functions for Forms

A static site's contact form posts to a Netlify Function, which validates the input, checks a spam score, and sends an email through a provider's API. Most of the time it responds in under 100 milliseconds. Sometimes — often the first submission of the morning, and on a quiet site that is most submissions — the reader clicks "Send" and waits two seconds with no feedback, and some click again, producing duplicate messages. That is a cold start: no warm instance of the function existed, so the platform had to start one, load the bundle and run its initialisation before handling the request.

This guide measures and fixes cold starts in a form handler on a Netlify-hosted Hugo marketing site, cutting cold-start latency from 1.9 seconds to about 420 milliseconds, and then makes the form behave well whatever the latency. It belongs to Serverless Functions for Static Sites; the form itself is described in Handling Form Submissions on a Static Site.

Step 1: Measure Cold and Warm Separately

Netlify's function logs show duration per invocation and mark initialisation time on cold starts (the Init Duration field, inherited from the underlying platform). A week of logs exported and grouped:

MeasureValue
Submissions412
Share that were cold starts71%
Cold start: init duration p501,620 ms
Cold start: total response p50 (client-measured)1,940 ms
Warm: total response p5086 ms
Duplicate submissions (same email, < 10 s apart)23

With a few dozen submissions a day, instances were almost always recycled between them, so most readers paid the cold start.

Client-measured form response time for cold and warm invocations Paired bars of milliseconds at the 50th and 95th percentiles. Cold invocations took 1,940 milliseconds at p50 and 2,710 at p95. Warm invocations took 86 at p50 and 140 at p95. Cold and warm response times p50 p95 Cold start 1940 ms 2710 ms Warm 86 ms 140 ms
Response time from submit to the response arriving, recorded by the form's script over one week; 71 percent of submissions were cold.

Step 2: Find What Initialisation Spends Time On

Cold-start cost is mostly loading and evaluating the bundle. Netlify bundles functions with esbuild; its metafile shows what is in the bundle:

npx esbuild netlify/functions/contact.mjs --bundle --platform=node --metafile=meta.json --outfile=/dev/null
npx esbuild-visualizer --metadata meta.json --filename bundle.html

The 4.2 MB bundle was mostly three things:

ModuleBundled sizeWhy it was there
aws-sdk v2 (whole SDK)2.9 MBImported for one S3 call that stored attachments
Email provider SDK610 KBWrapped one HTTP POST
validator + libphonenumber-js full metadata480 KBPhone number validation

All were imported at the top of the module, so every cold start loaded and evaluated all of them — even submissions without attachments or phone numbers.

Step 3: Shrink the Bundle

  • AWS SDK v2 → v3 client for S3 only (@aws-sdk/client-s3), imported lazily inside the attachment branch:
let s3;
async function storeAttachment(file) {
  s3 ??= new (await import('@aws-sdk/client-s3')).S3Client({ region: 'eu-west-1' });
  // ...
}
  • Email SDK → a direct fetch to the provider's REST endpoint, about 20 lines.
  • Phone validation → libphonenumber-js/min metadata, and only when the field is filled.

The bundle went from 4.2 MB to 190 KB for the common path; the S3 client loads only for the 8% of submissions with attachments.

Bundled size by module in the form handler before the fix Horizontal bars in kilobytes. The full AWS SDK version 2, 2,900. The email provider SDK, 610. Validation libraries with full phone metadata, 480. The handler's own code and small dependencies, 210. Bundle contents before the fix aws-sdk v2 (whole) 2900 KB Email provider SDK 610 KB Validation + phone data 480 KB Handler code 210 KB
From the esbuild metafile; after the fix the common-path bundle was 190 kilobytes.

Step 4: Do Less at Module Load

The handler also created clients and read configuration at the top level — harmless when warm, expensive when cold — and fetched the spam service's settings over the network on load. That fetch moved inside the handler, with the result cached in module scope for subsequent warm invocations:

let spamConfig;
export default async (req) => {
  spamConfig ??= await fetch(SPAM_CONFIG_URL).then((r) => r.json());
  // validate, score, send…
};

Netlify Functions also let you choose the deployment region; the function had defaulted to a US region while most readers and the email provider's API were in Europe. Moving it to eu-central-1 (a site setting) cut about 110 ms from every invocation, cold or warm.

Step 5: Consider an Edge Function

After the trim, the handler only needed fetch, so it could run as a Netlify Edge Function, whose lightweight runtime starts in tens of milliseconds and runs near the reader. The attachment path, which needs S3 signing, stayed in a regular function that the edge handler calls only when a file is attached. A one-week comparison:

Response p50Trimmed FunctionEdge Function
Cold420 ms70 ms
Warm74 ms45 ms

The team kept the trimmed regular function, because the edge version needed a second function for attachments and a different local development setup, and 420 ms was well within what the form's UI handles gracefully (next step). Netlify Functions vs Cloudflare Workers compares runtimes more broadly.

Step 6: Make the Form Honest About Latency

Whatever the backend does, the form now responds immediately: the submit button disables and shows "Sending…" on submit, the request carries an idempotency key so a double submission is ignored by the function, and the result replaces the form with a confirmation. A slow response is now a short wait with feedback rather than an apparently dead button.

Sequence of form behaviour from submit to confirmation Five steps. The reader presses Send. The button disables at once and shows Sending. The request is sent with an idempotency key generated when the form loaded. The function ignores any repeat of the same key. The confirmation replaces the form when the response arrives. The form's behaviour on submit Press Send reader action Button disabled "Sending…" at once POST + idempotency key from page load Function dedupes same key ignored Confirmation replaces form
Duplicate submissions fell to zero even before the cold-start fixes shipped.

Keeping the Bundle Small

Bundle size creeps back as features are added — a new field type, a CRM integration, a second email template. The CI job now builds the function with esbuild's metafile and fails if the common-path bundle exceeds 300 KB, printing the five largest modules so the cause is obvious. It fired once in the following quarter, when a developer added a date library for formatting one timestamp in the notification email; Intl.DateTimeFormat replaced it in three lines.

A synthetic check also submits the form in test mode every 30 minutes and records the response time, as in Synthetic Monitoring With Scheduled Playwright Checks. Because the check runs infrequently, almost every run is a cold start, which makes it a convenient tracker of cold-start latency over time: it has stayed between 380 and 470 ms since the changes.

Measured Impact

MeasureBeforeAfterTool
Bundle size (common path)4.2 MB190 KBesbuild metafile
Cold-start response p501,940 ms420 msClient timing
Warm response p5086 ms74 msClient timing
Duplicate submissions / week230Function logs
Form abandonment after clicking Send4.1%0.6%Analytics events

Pitfalls & Rollback

  • Whole SDKs for one call. Use modular clients or a direct fetch.
  • Top-level imports for rare paths. Import lazily inside the branch that needs them.
  • Network calls at module load. Move them into the handler and cache in module scope.
  • Silent buttons. Show progress and deduplicate on the server.
  • Rollback: each change is in the function's code or the site's function region setting; deploy the previous version to revert.

Conclusion

Cold starts in a Netlify Function behind a low-traffic form are mostly the cost of loading code the request does not need. Replacing a whole SDK with a modular client loaded on demand, calling the email API directly, trimming validation data, deferring network setup and moving the function next to its readers cut cold-start responses from 1.9 seconds to 420 ms — and an honest "Sending…" state with idempotency keys removed duplicate submissions entirely.

FAQ

What is a cold start in a Netlify Function?

The extra time taken when a request arrives and no warm instance of the function is available, so the platform must start a new runtime, load the bundled code and run its top-level initialisation before handling the request. On a low-traffic form, most submissions can hit a cold instance, because instances are recycled after a period of inactivity.

How long are Netlify Function cold starts?

It depends mostly on bundle size and what runs at module load. In this guide, a form handler bundling a full AWS SDK and an email library had cold starts of about 1.9 seconds; after trimming the bundle and deferring imports, cold starts were about 420 milliseconds. Warm invocations took 60 to 90 milliseconds either way.

Should I keep the function warm with scheduled pings?

It helps only partly. Pings keep one instance warm, but concurrent submissions or a different region can still start cold instances, and pings add invocations to the bill. Reducing cold-start cost itself, and designing the form so a slower response is not a problem, are more reliable.

Would an Edge Function avoid cold starts?

Edge Functions run on a lightweight runtime with much faster startup and run close to the user, so cold starts are typically tens of milliseconds. They have constraints, such as Deno APIs, limited execution time and no Node-only packages, so they suit simple form handling that calls an HTTP API, not code that depends on Node libraries.

Filed underFixesNetlify