How a pure reducer plus Resend collapsed the cost of running lifecycle email, and why the automation-SaaS calculus flipped.
For years the default answer to “we need lifecycle email” was to rent a marketing automation SaaS: MailerLite, Customer.io, Braze, Klaviyo. You rent the journey builder, the segmentation, the scheduler, and you pay per contact per month for the privilege. I just built the whole thing myself in a few hundred lines on top of Resend, and the cost-of-ownership math is not close. The genuinely hard part of email (deliverability, the MTA, IP reputation, bounce processing) is exactly the part Resend commoditised. Everything those automation products charge a premium for, the sequencing and branching and segmentation, turned out to be a pure function over my own database.
The engine is a state machine, not a journey builder
The whole drip system is one pure reducer. reduceEnrollment(state, steps, now, guards) takes an enrollment’s progress state, the campaign’s steps, the current time, and opt-out guards, and returns exactly one action: send, complete, stop, or wait. No IO, no clock, no DB reads. A sweeper is the only thing that ever interprets its decisions.
export type EnrollmentAction<TStep extends DripStep> =
| { kind: 'send'; step: TStep; nextState: { currentStepOrder: number; nextStepAt: Date | null } }
| { kind: 'complete' }
| { kind: 'stop'; reason: EnrollmentStopReason }
| { kind: 'wait'; until: Date }
export const reduceEnrollment = (state, steps, now, guards) => {
if (state.status === 'paused') return { kind: 'stop', reason: 'paused' }
if (guards.suppressed) return { kind: 'stop', reason: 'suppressed' }
if (guards.unsubscribed) return { kind: 'stop', reason: 'unsubscribed' }
const nextStep = ordered.find((s) => s.stepOrder > current)
if (nextStep === undefined) return { kind: 'complete' }
if (state.nextStepAt !== null && now < state.nextStepAt) return { kind: 'wait', until: state.nextStepAt }
return { kind: 'send', step: nextStep, nextState: { /* advance + schedule the one after */ } }
}
Because it is pure, it is exhaustively testable. Every branch (a paused enrollment, a suppressed address, the next due step, nothing left to send, a defensive “not due yet”) is a unit test with a literal input. There is no “preview in the vendor UI and pray” step, and no race condition on enrollment state, because the reducer never touches the world. That is the entire reason I reached for a reducer: a Redux-style pure function is the cheapest possible way to make sequencing logic both correct and reviewable.
O(sweeps), not O(enrollments)
The naive way to build a drip is a workflow per enrollment that sleeps between steps for days. That is how most homegrown drip systems die: a million enrolled users becomes a million sleeping Temporal workflows, and your orchestration bill scales with your audience.
I flipped it to a due-queue. Enrollment writes a row stamped with next_step_at. One scheduled sweep drains the queue in bounded batches and applies the reducer to each row.
// One scheduled run drains the due queue in bounded batches.
// Temporal cost is O(sweeps), not O(enrollments).
export async function dripSweepWorkflow(batchSize: number, maxBatches: number): Promise<void> {
for (let i = 0; i < maxBatches; i++) {
const result = await sweepDueEnrollments(batchSize)
if (result.claimed < batchSize) break
}
}
The claim is a FOR UPDATE SKIP LOCKED query that selects active enrollments whose next_step_at is due and not currently leased, then stamps a sweep_leased_until lease in the same transaction. Combined with Temporal’s ScheduleOverlapPolicy.SKIP, an overrunning sweep or a second worker replica can never re-grab the same row and double-send. The claim also checks (via subquery, so the lock stays on enrollment rows) that the parent campaign is still active, so pausing a campaign stops its in-flight sends without cancelling the enrollments. My orchestration cost is now flat regardless of how many people are mid-sequence.
Resend is just the MTA
The sender is about 130 lines. It POSTs to the Resend API with an Idempotency-Key derived from the natural identity of the send, so an at-least-once Temporal retry can never double-deliver.
// Resend honours an Idempotency-Key for up to 24h: a duplicate POST with the
// same key returns the original send instead of delivering again.
const dripIdempotencyKey = (enrollmentId: string, stepId: string): string =>
`drip:${enrollmentId}:${stepId}`
An in-process token gate caps outbound at 8 requests per second and backs off on a 429 using the server’s Retry-After, throwing a distinct ResendRateLimitedError so Temporal classifies it as retryable. Inbound webhooks (Svix-signed) map Resend events onto a monotonic status rank: pending → sent → delivered → opened → clicked, with bounced, complained, and failed terminal. A guard drops out-of-order webhooks, and hard bounces and complaints write to a suppression list that the reducer’s guards.suppressed check reads before any future send. RFC 8058 one-click unsubscribe stops every future email globally. None of that is exotic. It is the boring 20% you have to own, and it is small.
So why did the cost of ownership flip?
The monthly bill is the least of it. The real cost of a marketing automation SaaS is the contact-sync pipeline you have to run to feed it. Your branching logic, “send step 3 only if they still haven’t published their first post”, lives in the vendor’s UI, but the data it branches on lives in your Postgres. So you build and then maintain forever a sync: every relevant product event mirrored into their contact attributes, debounced, backfilled, reconciled when it drifts. That pipeline is the hidden cost, and it grows with every new rule you add.
With the reducer, the product is the source of truth and nothing leaves the database. Campaigns react to domain events I already emit. A lifecycle.feature_used event activates the first-post campaign; an audienceFilter is evaluated at enrollment time against my own tables. Branching is a SQL predicate, not a vendor DSL. There is nothing to sync because the data never went anywhere. Campaign definitions are config-as-code: in git, versioned, deployed through CI/CD, unit-tested. Compare that to a journey diagram that one person edits in a browser and nobody can review in a pull request.
So the calculus is this. When the sending primitive was bundled with the orchestration, the MailerLite model, buying made sense because the whole thing was hard. Once Resend unbundled the hard part into a clean API, the orchestration left over was a pure function plus a cron sweep. The cost of owning that fell through the floor. The cost of renting an automation layer I have to feed with a sync pipeline went the other way, because that cost is proportional to my domain complexity, and my domain only gets richer. The gap between those two curves is the whole argument.
Where I would still buy
I would not build an MTA. I will not chase deliverability, IP warming, or feedback loops, and I would not advise anyone to. That is Resend’s job and they are good at it, which is exactly why renting it is correct. The line I draw is simple: rent the commodity (sending), own the domain logic (who gets what, when, and why). A reducer is how you own the logic without it turning into a mess, and a due-queue sweep is how you run it without your orchestration bill tracking your headcount.
The pattern generalises. Any time a vendor sells you “the hard infrastructure plus a logic layer that needs your data,” and a newer vendor unbundles just the hard infrastructure, the build-versus-buy line moves under your feet. Email was the obvious one. It will not be the last.
Related
- OctoSpark Technical Decisions – Temporal, factory pattern, and the rest of the stack this sits on
- Technical Architecture – system design overview
- OctoSpark – project hub
- The MCP Abstraction Tax – same theme: paying an abstraction tax for a layer you did not need

