23 MAR 2026

The POST That Never Arrived

We shipped a contact form on a production site. HubSpot received the submissions. Slack got the notifications. Our own analytics tool — the one we built specifically to track where leads come from — received nothing.

No error in the console. No failed network request. No 4xx, no 5xx. The request simply never appeared in the server logs. Data was entering a form and vanishing between the browser and the server with zero evidence it had ever existed.

This is the story of two bugs that conspired to produce perfect silence.


The setup

The site runs React (Remix). When someone submits a form, three things happen in parallel:

  1. HubSpot gets the lead data via their API
  2. Slack gets a notification via webhook
  3. GROPE (our internal attribution tool) gets the submission with UTM params, referrer, and visitor ID

HubSpot and Slack worked fine. GROPE didn't. The tracking code looked correct:

const GROPE_ENDPOINT = "https://grope.app";

fetch(`${GROPE_ENDPOINT}/api/forms/submit`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
  keepalive: true,
}).catch(() => {
  // Silent fail — tracking should never break the user experience
});

That .catch(() => {}) is deliberate. Analytics should never prevent a form from working. If GROPE is down, the user should never know.

But it meant we'd silenced the one signal that would have told us something was wrong.


Bug one: the preflight that 500'd

GROPE is a separate app on a different domain. Browser sends a cross-origin POST, which means CORS preflight — an OPTIONS request that asks "am I allowed to do this?"

The CORS handler looked like this:

export function corsPreflightResponse(request: NextRequest): NextResponse {
  return NextResponse.json(null, {
    status: 204,
    headers: getCorsHeaders(request),
  });
}

NextResponse.json(null) throws a TypeError in Next.js. You can't serialize null as a JSON response body this way. The OPTIONS request hit the server, the server threw, the client got a 500 with no CORS headers. The browser saw "preflight failed" and blocked the actual POST.

The fix was one line:

return new NextResponse(null, {
  status: 204,
  headers: getCorsHeaders(request),
});

new NextResponse(null, ...) creates a no-body response. NextResponse.json(null, ...) tries to JSON-serialize the first argument. Same class, different static method, completely different behavior.


Bug two: the redirect that ate the body

Even after fixing the CORS handler, something else was wrong. The tracking script loaded from https://grope.app and posted data to https://grope.app/api/forms/submit.

But grope.app (bare domain, no www) is configured with a 307 redirect to www.grope.app. For normal browser navigation, this is invisible. For fetch with keepalive: true, it's lethal.

Here's what the Fetch spec says: when a request with keepalive: true encounters a redirect, the browser may follow it — but it strips the body. The POST becomes a bodiless POST. The server receives the request, finds no JSON body, and either errors or does nothing. The browser doesn't surface this because keepalive requests are fire-and-forget by design. They're meant for navigator.sendBeacon() use cases where the page is unloading and you don't care about the response.

The fix:

- const GROPE_ENDPOINT = "https://grope.app";
+ const GROPE_ENDPOINT = "https://www.grope.app";

Skip the redirect entirely. Post directly to the canonical URL.


Why I'm writing this

Two bugs. One produced a 500 that the browser swallowed because the catch handler was empty. The other produced a successful request that arrived with no body. Together, they created a system where data disappeared with zero observable symptoms.

Every debugging instinct I have failed:

  • Check the network tab? The keepalive request doesn't reliably show in DevTools because it's designed to outlive the page.
  • Check the server logs? The OPTIONS request 500'd, so the POST never reached the server. No log entry.
  • Check the error handler? We deliberately suppressed errors to protect the user experience.
  • Look for the 307? Nobody thinks to check redirects for API endpoints. The redirect worked fine for every other use case.

The only reason we found it was because someone submitted the contact form and the data showed up in HubSpot and Slack but not in GROPE. We went looking for a missing record and reverse-engineered the silence.


The lessons, if you want them

keepalive: true and redirects don't mix. If your endpoint might 307, your keepalive fetch will silently lose the request body. Always target the canonical URL directly.

NextResponse.json(null) is not the same as new NextResponse(null). The former tries to serialize. The latter sends an empty body. Use new NextResponse(null, { status: 204 }) for preflight responses.

Fire-and-forget patterns need an escape hatch. We suppressed errors to protect UX — which is correct. But we had no other way to observe failures. A periodic health check, a dashboard counter, even a console.debug behind a flag. Something. Silent fail should mean "the user doesn't see it," not "nobody sees it."

Two bugs can cooperate. Neither bug alone would have been hard to find. The CORS 500 would have shown up in the network tab. The redirect body-stripping would have been caught by a server-side log showing empty payloads. Together, they erased each other's evidence. The first prevented the request from reaching the server. The second ensured that if it somehow did reach the server, it would arrive empty.

The form works now. The fix was six characters on one side (www.) and a constructor change on the other. Two hours of debugging for two lines of code. Standard ratio, honestly.

Comments

Loading comments...