Skip to content
Plazoleta
· Raúl López

Fail-safe is chosen before it fails

On the Plazoleta website there are three systems that decide which side to fall on when something fails: the blog doesn't publish, analytics doesn't measure, and the form doesn't say 'thank you.' It's not pessimism: it's choosing the side before it's needed.

The contact form on this site has a rule written at the top of the file that handles it: it only responds 'thank you' if the email actually went out. If Resend fails, if configuration is missing, if the send only half completes, the response is an error that includes the address to write directly.

On a website whose goal is to get people to write, failing in plain sight looks like bad business. The alternative is more comfortable: always say 'thank you' and, if the notification didn't arrive, trust that the person will follow up. What happens is nobody follows up: they think they've already written to you. A lead lost in silence can't be seen, can't be measured, and can't be fixed. It's the kind of failure you find out about when the business has been flat for months and someone asks why.

Every system fails. The question that determines the damage isn't whether it will fail, but what it does when it fails. And when that moment arrives, it's already too late to decide: the side it falls on was written earlier, in a field's default value, in the catch someone added so the error would stop being annoying.

Three places with the side already chosen

The post that isn't published

The blog on this site publishes on its own, with no human review between the text and production. That's why src/lib/blog.ts leaves nothing to chance: the code itself decides what can be published.

The first piece is the status. If a post doesn't have estado, it isn't published:

// The default status is 'draft' on purpose: if someone forgets
// to set it, the post is NOT published. The fail-safe is not publishing.
const estado: PostEstado = data.estado === "publicado" ? "publicado" : "borrador";

The other two aren't as quiet: a post marked as published whose date isn't a valid YYYY-MM-DD, or whose text contains something shaped like a credential, blows up the build. The fail-safe is again not publishing, and here it's noisy on purpose: if the deployment is what fails, you find out that same day.

There's an asymmetry worth looking at. A draft doesn't appear in the index, or in the sitemap, or via direct URL. 'Unpublished' mode isn't an error warning: it's the system's default state. The error isn't that something stays out; the error would be something getting in without meeting the rules.

The analytics that doesn't measure

src/lib/analytics.ts applies the same question to privacy. Without a measurement ID there's no banner, no script, no events: the site behaves exactly as it did before analytics existed. The condition isn't 'load and then ask,' it's 'don't load until there's a yes.'

But the interesting part is the edge case: when localStorage throws an exception.

try {
  const valor = window.localStorage.getItem(CLAVE_CONSENTIMIENTO);
  return valor === "aceptado" || valor === "rechazado" ? valor : "sin-decidir";
} catch {
  // Storage blocked (strict private browsing): treat as undecided
  // and don't measure. When in doubt, don't measure.
  return "sin-decidir";
}

With storage blocked, you can't know whether the person said no or never got around to deciding, so it's treated as 'undecided' and nothing is measured. Consequence: the banner shows up again on every load. It's a small annoyance, chosen over the alternative —measuring someone who couldn't say yes— which can't be fixed afterward.

And the cost is written right next to it: you'll know nothing about the people who reject (in Spain, usually between 30% and 50% of traffic), so the GA4 numbers are a floor, not reality. Measuring less is a decision, not an accident.

There's also a small and very useful safeguard: evento() does nothing if gtag doesn't exist. Components can call analytics without checking first, and a forgotten if can't turn into an event sent without permission. The fail-safe is in the only place every event passes through.

The email that doesn't take down the send

Back to the form, because there are two emails and two different sides.

The internal notification —the one carrying the message and the person's email— is what matters. If it fails, the response to the user is an error with the contact address, so the lead isn't lost: the person knows they need to write. It's the conservative side, not promising what hasn't happened.

The confirmation to the sender is different. It goes out afterward, and if it fails it's logged and that's it: the response is still 'thank you.'

/**
 * Confirmation to the sender in their language. Deliberately does NOT return an error:
 * the email that matters (the notification with the lead) already went out; if this courtesy
 * fails, it's logged and the user response remains 'thank you'.
 */

Two emails, two opposite policies, and both are correct: the safe side isn't chosen by the type of operation, it's chosen by what's lost if it fails. In the first, a lead. In the second, a courtesy email that no longer adds anything because the original message is already in the inbox.

The question, asked in time

Seen this way, it seems obvious, and it isn't: most of the catch blocks I've written didn't decide anything, they covered things up. The difference between the three cases above and a catch { return ok } isn't the technique, it's whether someone asked the question before writing the code: when this fails, which side do I want to fall on?

The answer changes depending on what's at stake:

  • Does the failure destroy something that can't be undone? The published post, the data sent to Google, the 'thank you' that creates a false expectation. There you stop: fail closed, even if it hurts.
  • Can the failure be undone, or does it invalidate nothing? The confirmation that doesn't go out, the banner that reappears, the visible error that invites a retry. There you keep going, but you log it.
  • Is the failure silent? It's the only answer that's never acceptable. A system that fails and says everything went fine isn't fault-tolerant: it's lying. The user doesn't retry, the log doesn't exist, and the problem shows up months later, when you can no longer reconstruct what happened.

There's a fourth case worth recognizing because it's the most uncomfortable: when both options cost you. If the banner reappears, you annoy people; if you measure without consent, you're non-compliant. There's no free side: there's a side you can defend and one you can't. 'When in doubt, don't measure' is a product decision, not a technical note. And it's written where the catch is read, which is exactly where it needs to be read.

What we take away

A default value is a failure policy. The status that falls back to draft, the 'undecided' from storage, the error with a contact address: all three were written before anything failed, and all three decide what happens when it fails. If you don't decide, the most convenient path decides, and that's almost never the safe side.

Fail-safe is almost never 'keep going.' And when it is, it's because the failure is visible and cheap. What never holds up is false success: saying 'thank you' without sending, measuring without permission, publishing without the build validating it.

The cost has to be written next to it. Analytics loses the people who reject, the blog doesn't publish a post with a slip-up, the form shows an error where it could show a smile. These are real prices, and if they aren't written down, the next person who comes along will 'fix' them.

The full question —when this fails, which side do I want to fall on?— takes thirty seconds and is best answered with the system green than with the incident open. It's one of the few decisions that come free precisely because they're made beforehand.

If your system fails silently and you want to review which side it's falling on, write me.

Got a similar problem?

Tell us about it. You'll be talking straight to whoever writes the code.