Lesson 13 / 20
Notification and Email Services
The constraint that comes from handing sending to a service: bounced messages and complaints coming back as events, scanning this feedback stream's processing rate to measure the reputation score at day thirty, the number of throttled days, and the number of messages delivered, and comparing the cost of processing feedback against the delivery cost of not processing it.
Published Updated
In this lesson
- Defines the decision handed to the sending service and the reputation-bound quota that comes back in exchange
- Scans feedback processing rate to measure reputation, throttled days, and delivery count
- Compares the cost of processing feedback against the delivery loss of not processing it
Contents
The gateway brought incoming requests in. The measurement network also has an outward flow it sends: a work notification to the field crew when validation fails, a message to a subscriber when an invoice is cut, an alert to the branch supervisor on a meter fault. These sends, too, are handed to a service.
Deliverability itself was measured earlier and that measurement is not repeated here. This lesson’s question is: which constraint does handing sending to a service bring back? The answer comes from a place unlike any constraint seen so far — the send’s success is not in the sender’s hands.
The Handed-Over Decision and What Was Never in Your Hands
What is handed over is familiar: running the sending server, keeping address records, managing the retry queue, renewing domain verification records. There is one thing that is not handed over, because it was never in anyone’s hands to begin with: the receiving side’s decision. A message gets accepted, bounced, silently dropped into a folder, or marked as a complaint; the party making that decision is not the sender.
The service measures this decision and reflects it back to the sender as a reputation. The constraint that comes back follows from this: sending quota is bound to reputation. As reputation falls, quota falls; as quota falls, the number of messages that can be sent falls. In every lesson so far the constraint was a setting — visibility timeout, maximum deliveries, timeout threshold. Here the constraint is an earned number, and it cannot be tuned directly; it can only be raised through behavior.
The constraint also has a starting condition. A new sending stream has no history, so it has no reputation either; the service does not open up volume from the start, it opens it as history accumulates. The practical result is that sending capacity cannot scale instantly. In every service so far, scale arrived the moment it was demanded — queue depth rose and an instance was added, request count rose and the gateway kept up. Sending does not work this way: going from 8,000 messages a day to 80,000 is not a technical limit but an unearned permit. If the measurement network plans to bring a new region online, sending volume has to be ramped up gradually, weeks ahead of that date.
The way the service communicates this decision is also familiar. Bounces and complaints come back to the sender as events, written to an event bus or a topic just like the events in this topic’s first lesson. A direct consequence follows: if there is no rule to receive those events, they are dropped without producing any error. The 433 silent drops measured in the first lesson turn, here, into a list-hygiene problem.
The measurement runs on the fictional regional measurement network’s field notifications; the
addresses are fictional names in the form example.invalid.
- ED31. The list is 12,000 recipients; 480 are invalid from the start (closed account, bad domain).
- ED32. The daily base quota is 8,000 messages; the actual quota is proportional to reputation, and once reputation drops below 60 it is halved on top of that.
- ED33. Reputation starts at 100; if the daily bounce rate exceeds 0.02 it drops 6 points, if complaint rate exceeds 0.001 it drops 3 points, and it rises 2 points if both are below threshold.
- ED34. A complaining recipient stays on the list if not removed, and complains again on the next send.
- ED35. Feedback processing rate is how much of a day’s bounce and complaint events actually gets reflected onto the list.
- ED36. The model builds a single sending stream; content, subject line, and time-of-send effects are not measured. There is no randomness in the model.
The Link Between Processing Rate and Delivery
// reputation.mjs — notification service model: what happens to delivery if feedback goes unprocessed // Model: not a real send, but the link between feedback processing rate and delivery. const DAYS = 30, LIST = 12000, BASE = 8000; const INVALID0 = 480; // closed or bad address (fictional list) function run(p) { // p: feedback processing rate let invalid = INVALID0, complainers = 0, reputation = 100; let delivered = 0, bouncedTotal = 0, complaintsTotal = 0, throttledDays = 0; for (let g = 0; g < DAYS; g++) { const throttled = reputation < 60; // sending is throttled once reputation drops below the threshold const quota = Math.round(BASE * (reputation / 100) * (throttled ? 0.5 : 1)); if (throttled) throttledDays++; const sent = Math.min(quota, LIST); const bounced = Math.round(sent * (invalid / LIST)); const complaints = Math.round(sent * (0.0006 + complainers / LIST)); delivered += sent - bounced; bouncedTotal += bounced; complaintsTotal += complaints; const bounceRate = bounced / sent, complaintRate = complaints / sent; if (bounceRate > 0.02) reputation -= 6; if (complaintRate > 0.001) reputation -= 3; if (bounceRate < 0.01 && complaintRate < 0.0005) reputation += 2; reputation = Math.max(0, Math.min(100, reputation)); invalid = Math.max(0, invalid - Math.round(bounced * p)); // processed feedback complainers = Math.max(0, complainers + Math.round(sent * 0.0006) - Math.round(complaints * p)); } return { reputation, delivered, bouncedTotal, complaintsTotal, throttledDays }; } console.log("processing rate".padEnd(16) + "day-30 reputation".padStart(19) + "delivered".padStart(11) + "bounced".padStart(12) + "complaints".padStart(12) + "throttled days".padStart(16)); for (const p of [0, 0.25, 0.5, 0.9, 1]) { const s = run(p); console.log(p.toFixed(2).padEnd(16) + String(s.reputation).padStart(19) + String(s.delivered).padStart(11) + String(s.bouncedTotal).padStart(12) + String(s.complaintsTotal).padStart(12) + String(s.throttledDays).padStart(16)); } console.log(`days: ${DAYS}, list: ${LIST}, daily base quota: ${BASE}, ` + `invalid addresses at start: ${INVALID0}`);
node reputation.mjs
processing rate day-30 reputation delivered bounced complaints throttled days 0.00 0 40743 1697 65 25 0.25 7 62990 1450 84 24 0.50 88 211687 953 207 0 0.90 94 225548 532 150 0 1.00 94 225600 480 150 0 days: 30, list: 12000, daily base quota: 8000, invalid addresses at start: 480
The first row is the case where feedback is never processed. By day thirty, reputation is zero, sending was throttled on 25 of the thirty days, and total delivered stayed at 40,743. Nothing broke: the send request was accepted every day, the service returned no error, and bounce and complaint events were published correctly. Nobody just read them.
The last row is the same list, the same content, the same thirty days, at the opposite end. Feedback fully processed, reputation stays at 94, no day is throttled, and delivered climbs to 225,600. The gap between the two is 184,857 messages — 5.5 times as many.
The bounced column explains the mechanism. With full processing, total bounces are 480 — exactly the count of invalid addresses in the list: each invalid address bounces once, is processed, is removed, and is never tried again. Without processing, the same 480 addresses get retried for thirty days and produce 1,697 bounces. Bounce rate never falls, stays above threshold, and drags reputation down every single day.
The throttled-days column shows how this decline accelerates. The moment reputation drops below 60, quota is halved on top of the drop; in the first row, 25 of the thirty days are in this state. Throttling does not just cut delivery — it also thins the feedback stream, so finding the addresses that need cleaning slows down too. The constraint delays its own recovery.
The complaint column looks backward at first glance: as processing rate rises, complaint count climbs from 65 to 150. The reason is the denominator — in the unprocessed run, quota has already collapsed, so very few messages get sent at all. Complaint rate falls with processing, its count rises because volume rises. A gauge looking at raw counts reads this exactly backward.
Where the Threshold Sits
The real finding sits between the second and third rows. At a processing rate of 0.25, reputation is 7, delivered is 62,990; at 0.50, reputation is 88, delivered is 211,687. The relationship is not linear — doubling the processing rate more than triples delivered. The reason is threshold logic: the moment bounce rate drops below 0.02, the penalty stops and reputation starts rebuilding on its own. Enough cleanup to cross this threshold is all it takes; everything after that comes for free.
This is why “we process half of our feedback” is not a shortfall, it is a question of which side of the threshold you land on. Somewhere between 0.25 and 0.50, the system settles into one of two stable states: either the rate falls below the threshold and delivery climbs to its ceiling, or it stays above it and quota collapses to the floor. There is no middle ground, because a collapsed quota also lowers feedback volume, and the addresses that need cleaning get found even slower.
Processing Feedback Incorrectly
The first measurement showed the benefit of processing, but it assumed a single kind of processing: a bounced address gets removed from the list. Real feedback is not that uniform. Part of it is permanent — no such address, no such domain, account closed. Part of it is transient: mailbox full, receiving server busy, a temporary limit hit. Both often arrive from the same event stream, in similar-looking shapes.
// bounce-type.mjs — if the feedback type is not distinguished, who gets removed from the list const DAYS = 30, LIST = 12000, QUOTA = 8000; const PERMANENT0 = 480; // permanent error: no such address, no such domain const TRANSIENT = 0.03; // transient error: mailbox full, receiving server busy function run(typed) { let valid = LIST - PERMANENT0, invalid = PERMANENT0, wronglyRemoved = 0, delivered = 0; for (let g = 0; g < DAYS; g++) { const list = valid + invalid; const sent = Math.min(QUOTA, list); const permanent = Math.round(sent * (invalid / list)); const transient = Math.round((sent - permanent) * TRANSIENT); delivered += sent - permanent - transient; invalid = Math.max(0, invalid - permanent); // permanent errors are removed in both modes if (!typed) { valid -= transient; wronglyRemoved += transient; } // if transient errors are removed too } return { list: valid + invalid, wronglyRemoved, delivered }; } console.log("mode".padEnd(24) + "day-30 list".padStart(15) + "wrongly removed".padStart(17) + "delivered".padStart(11)); for (const [name, v] of [["no type distinction", false], ["only permanent removed", true]]) { const s = run(v); console.log(name.padEnd(24) + String(s.list).padStart(15) + String(s.wronglyRemoved).padStart(17) + String(s.delivered).padStart(11)); } console.log(`days: ${DAYS}, starting list: ${LIST}, daily quota: ${QUOTA}`);
node bounce-type.mjs
mode day-30 list wrongly removed delivered no type distinction 5025 6495 210020 only permanent removed 11520 0 232334 days: 30, starting list: 12000, daily quota: 8000
Without distinguishing type, 6,495 valid recipients were removed from the list over thirty days and the list fell from 12,000 to 5,025. Nothing was wrong with the ones removed; their mailbox just happened to be full that day. In the second row the list stays at 11,520 — the starting list with its 480 permanent-invalid addresses removed.
The flaw here runs opposite to the first measurement’s: there, feedback was not processed and reputation dropped; here, it is processed, more than enough, and delivered still drops — 210,020 instead of 232,334. Processing the feedback stream by itself is not enough; the event’s type has to be read. The gap sits in two lines of the function that reads the event, and those two lines cost 6,495 recipients. Getting the line between permanent and transient wrong points the same way as not processing the stream at all.
The Cost of Processing, the Price of Not
Processing the feedback stream is one of the cheapest jobs in this topic. In the fully processed run, thirty days bring in 480 bounce events and 150 complaint events: 630 total. If each event is processed with a single function call, at the call cost from the previous lesson, thirty days’ cost is 630 × 0.010 = 6.3 fictional units. Not processing it costs 184,857 undelivered messages.
Given a ratio this stark, it is common for this stream to go unprocessed, and the reason is written in the first lesson’s measurement. Feedback events are written to a topic; if that topic has no subscriber, the events drop silently. No error counter rises, no threshold is crossed — a topic with no subscriber looks like an empty queue, healthy. The symptom only shows up thirty days later, in a falling delivery count, and by then the cause read out is not the sending service but something like “the messages are not going through.”
How this flow should be built also follows from pieces already measured in this topic. Feedback events are written to a topic, the topic’s subscription is bound to a queue, the queue wakes a function, and the function writes to the list. The queue’s visibility timeout can stay short for this job, because writing to the list takes only a few units. That the same bounce event can be delivered twice is the second lesson’s result; removing an address from the list is already an idempotent operation, but if a complaint counter is kept, that counter needs to rest on an idempotency key just like the fourth lesson’s. This is where the topic closes: the sending service’s feedback stream is the single case in this course that uses all five of its lessons at once.
There are ways around the constraint and each carries its own countable cost. Cleaning the list by hand substitutes for processing feedback but lags: a weekly cleanup lands in the 0.25-to-0.50 zone of the table. Splitting sending into two — transactional messages on one stream, bulk announcements on another — protects one’s reputation from the other; its cost is two configuration surfaces and two feedback streams. Asking to have the quota raised does not remove the constraint at all: quota is the result of reputation, not its input. This is the strictest constraint counted in this topic — visibility timeout can be changed, a timeout threshold can be raised, a step limit can be redrawn; reputation can only be earned.
Summary
- Once sending is handed to a service, the decision that never comes back is the receiving side’s; the service reflects that decision back as reputation, and quota is bound to it. This constraint is not tuned, it is earned.
- A bounced message and a complaint come back to the sender as events; if there is no rule or subscription to receive them, they drop without producing any error.
- With feedback never processed, sending was throttled on 25 of thirty days, reputation fell to zero, and delivered stayed at 40,743; fully processed, reputation stayed at 94, zero days were throttled, and delivered reached 225,600 — a gap of 184,857 messages.
- With full processing, total bounces are 480, exactly the count of invalid addresses on the list; unprocessed, the same 480 addresses produce 1,697 bounces. The relationship is not linear either: raising processing rate from 0.25 to 0.50 raises delivered from 62,990 to 211,687, because the penalty stops the moment bounce rate drops below threshold.
- Failing to read the event’s type does the same damage in reverse: without distinguishing permanent from transient failures, 6,495 valid recipients were removed over thirty days and delivered fell from 232,334 to 210,020.
- Processing 630 feedback events costs 6.3 fictional units; not processing them costs 184,857 undelivered messages.
Next Step
Throughout this topic, functions got linked to each other. Events were routed by rule, queues and topics stood in as a buffer, multi-step flows were handed to a state machine, clock-triggered jobs were made idempotent, incoming requests were passed through a gateway, and outbound messages were tied to a feedback stream. The same account repeated in every case: a decision was handed over, and a countable constraint came back in exchange.
There is one property common to this whole arrangement, and it has never been asked so far: all of it runs in a single region. The rule engine, the queue, the orchestrator, the scheduler, the gateway, and the sending service all sit in the same place; a query from the branch screen and a request from the field app both go to that one place. The only thing close to the user was the cache — and a cache can only hold an answer already computed. The next topic takes on that limit: what is gained when the computation itself moves closer to the user, and which runtime constraints get accepted in exchange.
To keep your progress and take notes, Log in
My notes
Log in to take notes.