Dx Music League Part 3 : Shipping It


Part 3 of a 5-part series. Part 2 built the backend. Now it has to survive contact with the public internet — and with people who didn't build it.
There's a five-day gap in the commit history between June 29th and July 4th. Then July 4th happens: twenty-four commits in one day. This is the part of every project where the distance between "works for me" and "works for people" gets measured precisely, in commits.
The morning started with the last infrastructure piece: S3 + CloudFront for the frontend, added to the same CDK stack as everything else. Private bucket behind Origin Access Control, HTTPS, and the SPA fallback — 403/404 rewrites to /index.html — so React Router deep links work when someone opens /leagues/abc123 directly from a shared link.
Later that day came the deploy script, which encodes the one caching rule that every SPA eventually learns, usually the hard way:
app-3f9c2.js) sync with cache-control: public, max-age=31536000, immutable. They can cache forever; their names change when their contents do.index.html uploads separately with no-cache. It's the pointer to the current bundle. Cache it once and your users are pinned to a stale app until they think to hard-refresh — which they never do.One npm run deploy builds, syncs, and invalidates CloudFront. Boring. Perfect.
The launch feature was making leagues discoverable — until now, you needed an invite code someone sent you. It shipped as four deliberately thin vertical slices, each one deployable on its own:
visibility (private/public) and a player cap.Slicing it this way instead of building one big "public leagues" branch meant each piece was reviewable and testable alone — and when slice 4 needed the join rules, slices 1–3 were already live and boring.
Here's the theme of July 4th that no feature list captures: the UI was still lying, in the way every mock-first UI lies, and each lie had to die before strangers saw it.
I've come to believe this pass matters more than any feature: every dead control a real user clicks costs you more trust than a missing feature does.
The auth flows got the same treatment, because mock auth had let me skip the unglamorous edges: a confirm-password field on sign-up, the full forgot-password reset flow, and — the fix that saved actual beta users later — sign-in detecting an unconfirmed account and routing back to email verification with a fresh code, instead of dead-ending on an error.
Buried mid-day is the least glamorous, most important commit: "Generate random league ids + invite codes (fix cross-league collisions)."
League ids and invite codes had been derived from a module-level counter. Fine on a laptop. But Lambda resets module state on every cold start — so two leagues created across different cold starts could mint the same id. On a single dev machine this is nearly impossible to hit; in production it's a time bomb where two groups of strangers get merged into one league.
The fix: ids and invite codes come from crypto randomness, with invite codes drawn from an ambiguity-free alphabet (no I, L, O, 0, or 1 — because someone will always read a code aloud over voice chat). The lesson generalizes: any "it's fine, it's just a counter" in serverless code is a collision waiting for a cold start.
The most architecturally interesting feature of the day: leagues that run themselves. Set a start date and a phase length, and rounds advance automatically — submissions close, voting opens, results reveal — without the owner clicking anything.
The obvious design is a cron job. I didn't build one. Instead, progression is lazy, on read: whenever anyone loads the league (or submits, or votes), the backend checks whether the current phase's deadline has passed — or whether everyone has already finished — and advances the round through every phase it's owed. A league nobody opens for a week catches up in a single read. Finishing early re-bases the next deadline so the league doesn't stall.
No scheduler, no EventBridge rules, no missed-tick edge cases — with one honest trade-off: a round revealed at midnight looks revealed only when the first person shows up. For a game among friends, that's indistinguishable from correct. (A cron upgrade is designed and sitting in a drawer if it ever matters.)
The last stretch before inviting anyone acknowledged two realities. First: everyone would play this on their phone. The desktop sidebar became a bottom tab bar under 820px, with the safe-area inset handled for the iPhone home indicator and input font sizes bumped to 16px to defeat iOS Safari's focus auto-zoom.
Second: nobody reads instructions, but everybody needs them once. A wiki-style Help page explains the phases, the point pool, and the tie-break; and on July 5th, a six-step guided tour that appears on first sign-in — welcome, join or create, submit, listen, vote, leaderboard — replayable from Help forever after.
The same day, two features quietly disappeared behind gates: timed rounds (built, but not yet trusted with real leagues) and the YouTube Music picker option (read path proven, playlist story unfinished — part 5 is about exactly this). Shipping a beta is as much about choosing what to hide as what to show.
Then the invite went out to the group chat. A league called Szn Music Group 1 filled up with eleven real players, picked a theme, and started submitting songs.
Which is when the actual development began.
Next: Part 4 — Beta Week, in which real users find real bugs (someone's comments vanish, a settings field silently no-ops), demand anti-votes, and teach me what a music league actually needs.