At 11:36 AM on June 29, one click on Send Domain Instructions Email took the long way through our mail system.

The button sends one transactional message. A customer needs setup instructions for a domain, someone clicks the button, and the email should be on its way. Instead, that single send entered the same campaign mailer we use for batch work. The result was a slow button with no feedback while the request sat inside machinery built for a different job.

We had built one unified mailer because the common parts are common. Campaigns and transactional emails both need templates, recipient data, delivery rules, and a record of what was sent. Sharing those pieces was the right instinct. Where we went wrong was assuming a shared system meant every send should travel the same route.

A slow admin action, no errors

The first report described a slow admin action. There was no outage and no delivery failure. The email eventually went out, which made it easy to discount. The experience was still wrong: one operational message behaved like a batch send, and the interface never said it was working.

We traced the button through the send path and found the mismatch. The action did not send the domain-instructions template directly. It handed a one-recipient request to the full campaign mailer.

That explained the delay without needing a more exotic theory. The campaign route exists to manage campaigns, so it is the right place for a process that sends to a group. It is a poor place to make one person wait for a template they requested from an admin screen.

The useful comparison was already in the codebase. The sibling domain emails made no such detour. They sent their templates directly. That gave us the expected behavior and the narrower implementation. We did not need to redesign the mailer or invent a new subsystem. We needed to stop treating one message as a campaign.

Three ways out

There were three plausible fixes.

The first was to leave the transactional send in the campaign mailer and optimize that path. We rejected it because the batch system would then carry an exception for a job it was never meant to own. The second was a priority lane inside the campaign flow. That would have been faster, but it would still preserve the wrong abstraction, with one recipient remaining a special kind of campaign.

The third was what the sibling domain emails already did: send the domain-instructions template directly. That is the path we implemented.

The change was deliberately small. The button now invokes the transactional template send rather than routing through the full campaign mailer. Templates and the surrounding email behavior stay shared where they should be. The execution path is no longer shared just because the payload happens to be an email.

We also added a Sending state to the button. It did not fix the routing, but it fixed the second problem the routing exposed. Before, a person could click and see no sign that the request had been accepted. Once an action has real work behind it, the interface has to say so.

We shipped it as a hotfix and confirmed that production was serving the corrected path.

One label, two kinds of work

Reusing code was fine. The failure came when we put two operating patterns under one label and then stopped checking whether the label hid different performance needs.

A campaign send and a transactional send can both be described as “send email.” That description is too coarse to choose an execution path. One is batch oriented. The other is an immediate response to a specific action. The user does not care that they share templates or delivery infrastructure. They care that the instructions arrive when the button says they will.

This is easy to create in a mature system because the wrong route can still be correct enough. The message sends, the logs are not full of errors, and nothing crashes. The cost shows up as latency on a path that should feel direct.

We now treat each shared subsystem as two things: the shared contract, and the routes that implement it. Sharing a contract is valuable. Sharing every route can quietly add latency to an action that should feel direct, as it did here. When one component supports both batch and immediate work, we want tests and production checks that exercise each pattern on its own.