2026-07-26

Sprint 11 Closes: Building the Whole Notification Pipeline Before There's a Single Notification to Send

Some sprints ship a screen. This one shipped a pipe — the whole path a push notification travels, built end to end, while deliberately sending nothing anyone would actually want to receive. That sounds like a strange way to spend a sprint until you look at what's queued up behind it: a "the beer you saved is on tap tonight" alert, a nudge when someone's a few beers from a milestone, a win-back message after a customer's been away a few weeks, an owner who wants to announce a new arrival. None of that can exist until there's a mechanism to deliver it — and building the mechanism and the first real message at the same time is how you end up debugging two hard, unfamiliar things at once and not knowing which one is broken. So Sprint 11's job was only the mechanism, proved with a throwaway test message, and nothing else.

A service worker is the part that listens when nobody's home

A normal web page can't receive a push. The whole point of a push notification is that it arrives when the tab is closed and the phone is in a pocket, so there has to be something running outside any open page to catch it. That something is a service worker — a small script the browser keeps around in the background — and getting one installed is what turns the site into an installable app in the first place. On an iPhone that isn't optional: Safari only delivers web push to an app that's been added to the home screen, so "install the app" stops being a nice-to-have and becomes a hard precondition for the feature working at all.

I built that layer by hand — a small manifest, a minimal service worker, a few icons — rather than reaching for the popular plugin that generates all of it. The plugin does more, but most of what it does (offline caching) is explicitly out of scope here, and its generated worker only shows up in a production build, not the dev server I actually run the app against day to day. Hand-rolling it meant the worker exists in the exact environment I test in, with nothing new added to the build. The smallest thing that works, in the place I can actually see it working.

Opt-in, and never a nag

The opt-in flow has one rule that matters more than any of its code: it never asks on its own. There's no permission prompt on page load, no "enable notifications?" banner the first time you visit — the customer has to go to their account and turn it on deliberately. That's partly courtesy and partly self-interest: a browser remembers when you dismiss a notification request, and a site that burns that prompt on a first-time visitor who isn't ready has spent it for good. The screen handles the unglamorous states too — permission already denied, notifications unsupported on this browser, the iOS "add to home screen first" case — because those aren't edge cases, they're just Tuesday for a feature that depends on browser and OS permissions the app doesn't control.

A queue, a cap, and one row per person — not per phone

The actual sending happens off to the side, in the app's first-ever background service. A send to every customer can't block someone's confirmation from going through, so notifications go onto a queue and a background job drains it — fanning out to each of a customer's devices, quietly dropping any subscription the push service reports as dead, and recording that a send happened.

That recording is where the one genuinely interesting decision lived. The rule is "no more than N pushes per customer per window," and the obvious way to count is one row per message sent — except a customer with a phone and a tablet would then hit the cap twice as fast as someone with just a phone, purely for owning two devices. So the count is one row per notification, not per delivery: a single message that fans out to three devices ticks the cap exactly once. It's a small distinction that would never throw an error if you got it wrong — it would just silently rate-limit your most engaged customers hardest, which is precisely backwards.

Proving a pipe you can't see

The hardest part of an infrastructure sprint is knowing whether it works, because there's no screen to look at. The answer here is a single admin-only endpoint that sends a fixed test notification to the admin's own devices — the one seam that exercises the entire path (opt in, store the subscription, queue a message, run the job, respect the cap, deliver, prune the dead ones) without needing any of the real notification triggers to exist yet. It's the equivalent of running water through a new pipe before connecting anything to it: you're not testing the faucet, you're testing that the pipe holds. Everything downstream — the actual alerts and nudges — gets to assume the pipe holds, because this sprint proved it in isolation.

The merge that closed its own next step

The four pieces stacked in a straight line — the PWA, then the subscription store on top of it, then the opt-in on top of that, then the delivery job last — so I built them as four pull requests, each based on the branch below it. Merging that kind of stack bottom-up has a trap I walked straight into: when I merged the first one and let the tool delete its branch, the second pull request lost its base out from under it, and the platform responded by quietly closing it rather than pointing it at the main branch. Nothing was lost — every commit was safe — but a merged first step had silently taken its own next step offline. The fix was to recreate the deleted branch, reopen the closed request, re-point it at the main line, and from then on merge each step by re-pointing it first and deleting nothing until the whole stack had landed. A clean, cheap lesson: in a stack, the branch you're deleting might be holding up the one behind it.

The part that only works at a real address

There's a caveat I made sure to write down rather than discover in production: service workers and push only work in a "secure context" — HTTPS, or plain localhost on a developer's machine. That's fine on my laptop and fine on a real deployed site. It is not fine for the way this app has otherwise been usable all along — a phone opening it over the café's local network at a bare IP address, no HTTPS. That device gets the whole app except the one thing this sprint built: no service worker, no push. It's not a bug to chase; it's a property of the platform, and the honest move is to note plainly that the real end-to-end proof — a physical phone actually buzzing — waits on a proper HTTPS deployment, the same way the live social-sign-in round-trips have been flagged as verified-in-principle, exercised-for-real-later.

The PM angle

An infrastructure-only sprint is the one that's hardest to justify and easiest to quietly ruin. Hard to justify because it ends with nothing a user can see — no screen to demo, no "look what it does." Easy to ruin because the obvious way to make it feel more real is to smuggle in the first actual notification — the on-tap alert, the milestone nudge — so there's something to show. That's exactly the move to resist. The reason to keep the pipe and the water separate isn't purity; it's that when the first real notification eventually misbehaves, I want to already know, with certainty, that the delivery mechanism underneath it is sound — because it shipped a sprint earlier, on its own, proven by a test send. Scope discipline on a foundation sprint isn't about doing less; it's about making sure the next sprint gets to stand on something it doesn't have to re-verify. The test-send endpoint is what turns "trust me, the pipe's fine" into "here, watch me run water through it."

Where the project stands

Four issues (#101–#104), four stacked pull requests (#124–#127), merged in order onto master, milestone closed. Backend suite: 414 passing. Frontend suite: 264 passing. Clean production build. The whole path — migrations, the opt-in, subscribe and unsubscribe, the admin test send and its permission gating — was verified live against a real Postgres database and the running stack, with the single exception of a physical phone receiving a push, which is written down as the manual step it has to be. Next up is everything this pipe was built to carry: badges, the automated notifications, the owner's composer, the want-list "it's on tap" alerts — all of it now unblocked, and all of it deliberately still ungroomed until it's actually its turn.

Comments

Loading comments...