Skip to content
Top 1% Upwork (8 years) 286+ client deployments 2,036+ projects shipped GoHighLevel Certified Partner Featured speaker: GHL Summit 2025 Client Login
← All issues
The Scale Brief · Issue #152

Your form returned success.
Half the data never landed.

A contact appeared in the CRM. Name, email, phone — all present. The note attached to that contact — the field where the prospect actually typed what they needed — was gone. Nothing in the logs. Nothing in the response. We shipped a fix this week. The cause is a Cloudflare Workers gotcha that quietly kills any operator running serverless lead-capture.

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 rule Any I/O you start without awaiting inside a Cloudflare Worker dies the instant your handler returns. Not "might die." Will die. There is no global event loop keeping it alive.

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 waitUntil from the function context (it's already on ctx — 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.

T+0
POST 15 synthetic submissions covering every edge case (email-only, phone-only, malformed, international, special chars, huge notes, CORS preflight, etc.)
T+30s
All 15 returned ok:true. Contact IDs valid. Tags correct.
T+45s
GET /contacts/{id}/notes for each test contact. 0 notes returned.
T+2min
Direct cURL → GHL with same payload → HTTP 201, note created. So the call worked. It just never fired in production.
T+5min
Re-read the worker. Spotted the unawaited .catch(). Confirmed the CF Workers runtime kills it on return.
T+8min
Two-line fix. Deploy. Re-test. Notes attaching. Done.

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:

  1. Search your codebase for fetch( calls that aren't followed by await.
  2. 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.

★ Audit your own forms
A free 30-minute call. We pick three forms on your site and run the same adversarial sweep that found this bug. You see the gaps live.
Apply for the audit
Side-channel-verify every funnel you ship

The Scale Audit runs the same adversarial sweep
against your own form endpoints.

Three forms, traced live. We POST synthetic submissions and read back what actually landed in your CRM. The gaps show up in minutes, not months.

Apply for a free audit All issues