Here is a lead capture endpoint. It looks fine. It is not fine.
export async function POST(request: Request) {
const lead = leadSchema.parse(await request.json());
await inngest.send({ name: "lead/captured", data: lead });
return Response.json({ ok: true });
}The workflow behind that event does everything: enrich the lead, score it, store it, notify me, place the callback. It retries. It is durable. That is the whole reason for using a workflow engine.
And every one of those guarantees starts after the event is accepted.
The gap nobody looks at
Between the visitor pressing submit and the event being accepted, the lead exists in exactly one place: memory, in a function that is about to exit.
If the event send fails, the lead is gone. Not delayed. Gone. The visitor sees a success screen, because you were right to show them one — they did their part. But there is no row anywhere, no retry queue, nothing to replay. The only trace is whatever you happened to write to your logs.
I shipped this exact bug. The tell was a line in my own error handler:
[lead] INNGEST_EVENT_KEY is not set — lead captured to logs only.
I wrote that line myself, deliberately, as a graceful degradation. Missing env var, don't break the site, log it and move on. It even worked as designed. What I had not thought through was that "captured to logs only" is a polite way of saying lost, and that the same code path runs whenever the event send fails for any reason — a bad key, a network blip, a provider incident.
The reorder
export async function POST(request: Request) {
const lead = leadSchema.parse(await request.json());
const leadId = await db.saveLead(lead); // durable HERE
await inngest.send({ name: "lead/captured", data: { leadId } });
return Response.json({ ok: true });
}Two lines moved. Now the worst case is completely different: a workflow outage
delays follow-up. It does not destroy the lead. The row is sitting there with
status: "new", and when the workflow comes back you can replay every one of
them, because you know exactly which ones never advanced.
That status field is doing real work. It is not decoration — it is the record of how far the pipeline got:
new → enriched → notified
A lead stuck on new is a lead the workflow never picked up. You can query for
that. You cannot query your logs.
The bonus you did not ask for
Once the lead is a row, the event does not need to carry the lead any more. It carries a pointer:
await inngest.send({ name: "lead/captured", data: { leadId } });Which means no names, no emails, no phone numbers on the event bus at all. The workflow loads the record it needs, when it needs it. That was not why I made the change, but it is the kind of thing that happens when you put data in the place data belongs.
Where this does not apply
If the work is genuinely fire-and-forget — an analytics ping, a cache warm — skip the write. Storing something you will never read is its own smell.
The test is one question: if this disappeared, could I get it back? A page view, yes, there will be another. A stranger's phone number typed once, at midnight, from an ad you paid for — no. Write that one down first.