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:
| Measure | Value |
|---|---|
| Submissions | 412 |
| Share that were cold starts | 71% |
| Cold start: init duration p50 | 1,620 ms |
| Cold start: total response p50 (client-measured) | 1,940 ms |
| Warm: total response p50 | 86 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.
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:
| Module | Bundled size | Why it was there |
|---|---|---|
aws-sdk v2 (whole SDK) | 2.9 MB | Imported for one S3 call that stored attachments |
| Email provider SDK | 610 KB | Wrapped one HTTP POST |
validator + libphonenumber-js full metadata | 480 KB | Phone 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
fetchto the provider's REST endpoint, about 20 lines. - Phone validation →
libphonenumber-js/minmetadata, 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.
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 p50 | Trimmed Function | Edge Function |
|---|---|---|
| Cold | 420 ms | 70 ms |
| Warm | 74 ms | 45 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.
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
| Measure | Before | After | Tool |
|---|---|---|---|
| Bundle size (common path) | 4.2 MB | 190 KB | esbuild metafile |
| Cold-start response p50 | 1,940 ms | 420 ms | Client timing |
| Warm response p50 | 86 ms | 74 ms | Client timing |
| Duplicate submissions / week | 23 | 0 | Function logs |
| Form abandonment after clicking Send | 4.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.
Related
- Parent: Serverless Functions for Static Sites — dynamic features for static sites.
- Handling Form Submissions on a Static Site — the form this function serves.
- Netlify Functions vs Cloudflare Workers — runtime trade-offs, including cold starts.
- Netlify Edge Functions for Static Sites — the edge alternative tested here.
- Rate Limiting Edge Functions — protecting the same endpoint.