research/isolating-failures-in-a-form-pipeline
Isolating Failures in a Form Pipeline: Save First, Notify Independently
Engineering Note · October 29, 2026 · 9 minute read · Forms · Reliability · Cloudflare
A form pipeline that isolates failures runs its save step and its notification step independently, so one failing step never discards a message the other already kept. The visitor sees an error only when both fail, because that is the only case in which we do not have their message.
A contact form has one job that matters: the message must not get lost. A pipeline that treats saving, emailing, and replying as one chain breaks that promise. If the email provider has a bad minute, the database write that already succeeded is thrown away along with a 500 response, or the reverse. We build the pipeline so each step can fail on its own, and the visitor is told the truth about whether we have their message.
The pipeline
Every form on the site runs the same sequence:
- Honeypot check.
- Turnstile verification.
- Validate and sanitize the input.
- Persist the submission (a D1 insert) and send the internal notification email, as independent steps.
- Optionally, send a confirmation email to the submitter.
submit
|
v
1 honeypot filled? ---- yes --> drop silently
| no
v
2 Turnstile ----------- fail --> error to visitor
| pass
v
3 validate + sanitize -- fail --> error to visitor
| pass
v
4a persist (D1 insert) 4b notify (internal email)
| failure caught + logged | failure caught + logged
+-------------+---------------+
v
both failed? ---- yes --> 500
| no
v
200 --> 5 optional confirmation email
The first three steps are gates. If any of them fails, nothing has happened yet and the visitor gets an error. Step 4 is where isolation matters, because it is the first point where we have accepted the message and have two ways to keep it.
Why keep two independent records?
Saving to the database and notifying us by email are two different ways of not losing the submission. Neither should depend on the other. We run both and catch each failure separately, logging it with enough context to find later.
The decision rule is simple:
- If the form defines a persist step, the visitor sees success when either the save or the internal email worked. They get a 500 only when both failed.
- If the form has no persist step, the internal email is the only record. Its failure is then a hard failure, and the visitor is told so.
In code, the shape is roughly this:
const [saved, emailed] = await Promise.allSettled([
persist(submission),
notifier.send(message),
]);
if (saved.status === "rejected") console.error("persist failed", saved.reason);
if (emailed.status === "rejected") console.error("email failed", emailed.reason);
if (saved.status === "rejected" && emailed.status === "rejected") {
return Response.json({ error: "Please try again" }, { status: 500 });
}
return Response.json({ ok: true });
Promise.allSettled waits for both promises and never throws, so one rejection cannot cancel the other. The same idea applies without it: wrap each step in its own try and catch.
The reasoning is about what the visitor can act on. If we have the message in either place, telling them it failed would make them send it again and create duplicates. If we have it in neither place, they must know, so they can try another route.
What does each design do when a step fails?
We simulated three handlers with injected failures and a visitor who retries once after any 500: a chained handler (save, then email, any throw is a 500), Promise.all, and Promise.allSettled. The steps are fakes that increment a counter, so the table counts exactly how many rows were saved and emails sent. This is a simulation of the control flow, not our production handler; Node v22.22.2 runs the file directly because it strips TypeScript types.
// pipeline.ts - simulation of "persist and notify independently". Run: node pipeline.ts
type Submission = { name: string; message: string };
type Step = (s: Submission) => Promise<void>;
type Result = { status: number };
type Handler = (s: Submission) => Promise<Result>;
// Isolated: both steps run, each failure is caught on its own.
// A form with no persist step (null) has the email as its only record.
const isolated = (persist: Step | null, notify: Step): Handler => async (s) => {
const [saved, emailed] = await Promise.allSettled([persist ? persist(s) : Promise.reject(new Error("no persist step")), notify(s)]);
if (persist && saved.status === "rejected") console.error("persist failed", saved.reason.message);
if (emailed.status === "rejected") console.error("email failed", emailed.reason.message);
return { status: saved.status === "rejected" && emailed.status === "rejected" ? 500 : 200 };
};
// Chained: persist, then notify; any throw is a 500.
const chained = (persist: Step, notify: Step): Handler => async (s) => {
try { await persist(s); await notify(s); return { status: 200 }; } catch { return { status: 500 }; }
};
// Promise.all: starts both, but rejects as soon as either rejects.
const allOrNothing = (persist: Step, notify: Step): Handler => async (s) => {
try { await Promise.all([persist(s), notify(s)]); return { status: 200 }; } catch { return { status: 500 }; }
};
let rows = 0, emails = 0;
const dbOk: Step = async () => { rows++; };
const dbDown: Step = async () => { throw new Error("D1 unavailable"); };
const mailOk: Step = async () => { emails++; };
const mailDown: Step = async () => { throw new Error("provider 503"); };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
console.error = () => {}; // keep the table clean; the isolated handler logs both failures
const scenarios: [string, Step, Step][] = [
["persist ok, email ok ", dbOk, mailOk],
["persist ok, email FAIL", dbOk, mailDown],
["persist FAIL, email ok ", dbDown, mailOk],
["persist FAIL, email FAIL", dbDown, mailDown],
];
const impls: [string, (p: Step, n: Step) => Handler][] = [["chained", chained], ["Promise.all", allOrNothing], ["allSettled", isolated]];
console.log("node", process.version);
console.log("one submission, then one retry by the visitor if the first answer was a 500");
console.log("scenario".padEnd(26), "impl".padEnd(12), "1st", "2nd", "rows", "emails");
for (const [label, p, n] of scenarios) for (const [name, make] of impls) {
rows = 0; emails = 0;
const h = make(p, n);
const first = (await h({ name: "Ada", message: "Hello" })).status;
await sleep(5); // let any still-running sibling step settle
const second = first === 500 ? String((await h({ name: "Ada", message: "Hello" })).status) : "-";
await sleep(5);
console.log(label.padEnd(26), name.padEnd(12), String(first).padEnd(3), second.padEnd(3), String(rows).padEnd(4), emails);
}
console.log("form with no persist step (isolated handler only)");
for (const [label, n] of [["no persist, email ok ", mailOk], ["no persist, email FAIL", mailDown]] as [string, Step][]) {
rows = 0; emails = 0;
const status = (await isolated(null, n)({ name: "Ada", message: "Hello" })).status;
console.log(label.padEnd(26), "allSettled".padEnd(12), String(status).padEnd(3), " ", String(rows).padEnd(4), emails);
}
node v22.22.2
one submission, then one retry by the visitor if the first answer was a 500
scenario impl 1st 2nd rows emails
persist ok, email ok chained 200 - 1 1
persist ok, email ok Promise.all 200 - 1 1
persist ok, email ok allSettled 200 - 1 1
persist ok, email FAIL chained 500 500 2 0
persist ok, email FAIL Promise.all 500 500 2 0
persist ok, email FAIL allSettled 200 - 1 0
persist FAIL, email ok chained 500 500 0 0
persist FAIL, email ok Promise.all 500 500 0 2
persist FAIL, email ok allSettled 200 - 0 1
persist FAIL, email FAIL chained 500 500 0 0
persist FAIL, email FAIL Promise.all 500 500 0 0
persist FAIL, email FAIL allSettled 500 500 0 0
form with no persist step (isolated handler only)
no persist, email ok allSettled 200 0 1
no persist, email FAIL allSettled 500 0 0
Read the rows where something fails:
- Save works, email fails. The chained handler and
Promise.allboth answered 500, so the visitor retried and we stored the message twice. TheallSettledhandler answered 200 and stored it once. - Save fails, email works. The chained handler never reached the email step, so the message went nowhere.
Promise.allstarted both steps, sent the email, and still answered 500, so the retry sent a second email.allSettledanswered 200 with one email. - Both fail. Every design answers 500 and stores nothing, which is correct: the visitor should know.
- No save step. With the email as the only record, success follows the email and a failed email is a 500.
Failure modes
The simulation also exposes costs of the isolated design that are worth planning for:
- Save works, email fails, visitor sees success. The row is in the database but nobody was notified. The failure is logged, but someone has to look at the logs or the table. We log each failure with context for this reason.
- Save fails, email works. The inbox is now the only record. That is acceptable for a contact form and the reason the persist and notify steps are redundant on purpose.
- A step that hangs. The simulation only injects steps that reject quickly.
Promise.allSettledalso waits for slow steps, so give each call a timeout in production. We did not simulate timeouts here.
Keep the provider in one file
Email delivery sits behind a small NotificationService interface. Callers ask it to send a message and don’t know who delivers it. The provider-specific code lives in a single file, and today that provider is Resend. Swapping providers, or adding a fallback, means changing that file and nothing in the pipeline.
The same boundary makes testing easier. A test can pass in a fake service that fails on demand, which is how we exercise the “email failed but the save worked” branch without touching a real provider. The mailDown step in the simulation above is exactly that kind of fake.
How do you escape user text in emails?
Anything the visitor typed is untrusted. When we build an HTML email, every user-supplied value is escaped before it goes into the markup. A message containing angle brackets should arrive as text, not as markup that renders in someone’s inbox. The same discipline applies to text that ends up in a model prompt, which we cover in untrusted text in model prompts.
A minimal escaper and what it does to a hostile message:
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
const message = `<img src=x onerror="alert(1)"> & 'quotes'`;
console.log("raw in HTML :", `<p>${message}</p>`);
console.log("escaped :", `<p>${esc(message)}</p>`);
raw in HTML : <p><img src=x onerror="alert(1)"> & 'quotes'</p>
escaped : <p><img src=x onerror="alert(1)"> & 'quotes'</p>
Forms as configuration
We don’t write a handler per form. A form is defined as a config object: its fields, their labels, their maximum lengths, and the success message. That config is passed to a createFormHandler function, which returns the endpoint handler running the pipeline above.
Adding a form means writing a config, not copying and editing a pipeline. The gates, the isolation rule, the escaping and the logging are written once, so a fix applies to every form at the same time.
When this doesn’t apply
This design assumes a human is reading the internal inbox and the database. If a submission triggers something with strict ordering needs, such as charging a card, you need a stricter flow with a clear source of truth. For contact and inquiry forms, where the goal is that a person eventually reads the message, two independent records and an honest failure rule are enough.
Related reading
- A Cloudflare Turnstile Integration Checklist for Contact Forms
- One Config for Client and Server: A Single Source of Truth for Intake Forms
- Testing a form flow that depends on bot checks and AI
Frequently asked questions
How should a contact form handle a failed email or a failed database write?
Treat them as two independent records of the same message. Run both, catch each failure separately, and log it. Return success if either one worked, and an error only if both failed. If the form has no database step, the email is the only record, so its failure must be reported to the visitor.
Why use Promise.allSettled instead of Promise.all for a form handler?
Promise.all rejects as soon as one step rejects, so the handler answers 500 even though the other step may have succeeded. In our simulation, a save that worked plus an email that failed returned 500, and the visitor's retry produced two saved rows. allSettled waits for both and lets you decide from the pair of results.
When should a contact form return a 500 error?
Only when the message is stored nowhere. If the database save or the internal email succeeded, the visitor should see success, because an error makes them resubmit and creates duplicates. If both failed, or the only configured step failed, return the error so the visitor can use another route to reach you.
How do you keep an email provider out of your form pipeline logic?
Put delivery behind a small interface, such as a NotificationService with a send method, and keep provider-specific code in one file. The pipeline calls the interface and never names a provider. Swapping or adding a fallback provider changes that one file, and tests can inject a fake service that fails on demand.
How do you stop visitor input from breaking an HTML notification email?
Escape every user-supplied value before inserting it into the email markup, replacing ampersands, angle brackets and quotes with entities. A message containing an img tag with an onerror attribute then arrives as visible text instead of markup that renders in the recipient's inbox. Apply the same suspicion to text that reaches a model prompt.