This one had been bleeding for months. We caught it during a stress-test sweep on our own /api/lead endpoint — the same code that captures form submissions from every page on this site. The contact was in GoHighLevel. The tags were correct. The note was missing.
Every form submission with a company name, a website URL, a "what are you trying to fix" field, or any rich context beyond the three core identity fields was silently losing that context. The submitting visitor saw a success state. The CRM looked healthy. Sales reps got contacts with no story attached.
The symptom
POST a form. Get back the happy path:
{
"ok": true,
"delivered": true,
"contact_id": "yOEpxBkk3kuG2oS01c3S",
"opportunity_id": null,
"tags": ["as-website", "form-audit", "src-qa-matrix-..."]
}
Open the contact in the CRM. Identity fields all present. Open the contact's notes:
GET /contacts/yOEpxBkk3kuG2oS01c3S/notes/
→ { "notes": [], "traceId": "c2ab..." }
Zero notes. Every submission. For months.
The clincher: a direct cURL to the same GHL endpoint with the same payload created the note instantly. HTTP 201. So the request was valid. The body was valid. The credentials were valid. The fetch never reached GHL.
The five lines
Here's the original worker code. See if you can spot it before reading on:
if (notes) {
fetch(`${GHL_BASE}/contacts/${contactId}/notes`, {
method: "POST",
headers: { ... },
body: JSON.stringify({ body: notes }),
}).catch(() => {});
}
return json({ ok: true, delivered: true, contact_id: contactId });
It looks fine. A separate POST to attach the note as a side-effect. The .catch(() => {}) swallows any error so the main response isn't blocked. The pattern is so common it's idiomatic in Node.js services.
This is also exactly the trap.
What Cloudflare Workers actually does
In Node.js, the process keeps running. An unawaited fetch().catch() happily runs to completion in the background. The response returns immediately; the side-effect lands a few hundred ms later. Everyone wins.
In Cloudflare Workers (and Pages Functions), the process does not keep running. The runtime is event-driven. The moment you return a Response object, the runtime considers your work done. Any pending promises are terminated. The fetch you fired-and-forgot is killed mid-flight.
Your response says success. The side-effect never happens. There is no error. There is no log. The runtime just stops.
The one-line fix
Cloudflare gives you a single primitive to opt out: ctx.waitUntil(promise). It tells the runtime "this work is in-flight, keep me alive until it settles."
async function handleLead({ request, env, waitUntil }) {
// ... existing logic ...
if (notes) {
const noteWrite = fetch(`${GHL_BASE}/contacts/${contactId}/notes/`, {
method: "POST",
headers: { ... },
body: JSON.stringify({ body: notes }),
}).catch(() => {});
if (typeof waitUntil === "function") waitUntil(noteWrite);
}
return json({ ok: true, delivered: true, contact_id: contactId });
}
Two changes:
- Pull
waitUntilfrom the function context (it's already onctx— just unused). - Wrap the fire-and-forget promise so the runtime extends the lifetime past
return.
Deploy. Re-test the same payload. Note attaches in < 2 seconds. Verified end-to-end.
How we found it
Not by reading code. Not by waiting for a customer to complain. By running an adversarial test matrix against our own endpoint and inspecting what actually landed in GHL — not what the API responded with.
ok:true. Contact IDs valid. Tags correct./contacts/{id}/notes for each test contact. 0 notes returned..catch(). Confirmed the CF Workers runtime kills it on return.Where else this lurks
Every operator I know running serverless has at least one of these. Common patterns that look fine in Node and silently fail in CF Workers:
- Analytics / log shipping — fire a beacon to your data warehouse after returning the response. Lost.
- Webhook fan-out — main handler returns 200, secondary handler dispatches to N downstream services. Lost.
- Email send-after-save — save the contact, return success, then async-send the welcome email. Lost.
- Audit log writes — write the user action to a slow log store. Lost.
- Webhook signature verification + tracking — process the webhook, return 200 fast, async-track the event for replay protection. Tracking lost.
The deceptive part: it works in local dev. wrangler dev runs a Node.js shim that does keep the event loop alive. The bug appears only in production where the actual edge runtime enforces the rule.
The two-question audit
If you run anything on Cloudflare Workers or Pages Functions, walk this in 60 seconds:
- Search your codebase for
fetch(calls that aren't followed byawait. - For each one — is the call's side-effect critical? If yes, wrap it in
ctx.waitUntil(). If no, leave it.
That's it. The whole bug. Every operator running serverless has at least one of these. Most don't know yet.
The deeper lesson
The bug wasn't in the code. The bug was in the trust we placed in the response shape. Status 200 with a contact ID looked like success. The downstream side-effect — the part that actually mattered to sales — was invisible from the response.
The fix is not just waitUntil(). The fix is also: never trust a happy-path response alone. Read the system state separately. Run an adversarial test matrix against your own endpoints. POST a test submission and then immediately query the CRM for what actually landed. Compare. Diff. Surface the gap.
This is the practice that found our bug. And it's the practice we install for every client. The funnels we build aren't just "deployed and shipped." They're verified end-to-end with side-channel checks that don't trust the response shape.