By 12:21 on a Thursday we had shipped two production hotfixes and merged a third change to a preview branch. Nobody had told us which lane each change belonged in. We didn’t decide by diff size, release window, or which one we felt like doing first. We decided by what each change could break if we got it wrong.

The heuristic is boring on purpose: route a change by its blast radius. A production-affecting bug you can verify gets a hotfix and ships now. An internal refactor with no user-visible behavior rides the preview branch and goes out in the normal release. People tend to route by size (“it’s only four lines, just push it”) or by schedule (“we ship Fridays”), and both get you in trouble.

Here is the morning, concretely.

Hotfix one: the yellow box

An event page in the legacy application was throwing our internal-error screen, the dreaded “yellow box,” whenever it hit a missing or bad event ID. The page built a URL out of query-string fragments and handed it to Response.Redirect. Response.Redirect issues a 302 by writing a Location header, and if the response buffer is already in flight, that throws. Our error-handling wrapper catches the throw, logs to the error table, and transfers to the internal-error page. So a missing ID, which should have been a graceful bounce, turned into a full error screen for the user.

The fix was four lines in, twenty-four lines out: swap both Response.Redirect calls for a thin Server.Transfer wrapper. Server.Transfer re-routes execution server-side with no new HTTP round trip and no header dependency, so it can’t hit the “headers already sent” failure mode. We also left a // PENDING in the code, because the home page it falls back to still loads with its own bugs. We treated the acute symptom and marked the deferred root cause instead of pretending it was clean.

It was user-facing, in production, and reproducible. Hotfix. It went out as a tagged release at 09:59.

Hotfix two: a global name collision

The second one surfaced a couple of hours later, and nothing in our code had changed. The “copy embed code” button stopped working for some users. An aging library and a brand-new browser API were fighting over the same global variable name.

We ship an older build of clipboard.js. Its UMD wrapper assigns the constructor to window.Clipboard, and our code did new Clipboard("#copy_button"). That was fine for years. Then modern browsers shipped the Async Clipboard API, which puts a native, non-constructible Clipboard object on window. Depending on load order and browser, window.Clipboard is now the native object, and new Clipboard(...) throws because you can’t new it. The result was a silent copy failure. Nobody reported an error, just a button that did nothing.

We didn’t upgrade the library. An old minified blob loaded on a lot of pages, in an app with no module bundler, is a bad thing to bump for a single bug. The lower-risk move was a defensive shim at the call site. It feature-detects which constructor you actually have (window.ClipboardJS || window.Clipboard), checks that it’s really a function, wraps the construction in try/catch, and degrades. The fallback chain ends at navigator.clipboard.writeText() for the native path, then document.execCommand('copy') against an offscreen textarea for old browsers.

Also user-facing, in production, reproducible and verifiable. Hotfix. It went out tagged at 12:21.

The refactor that waited

The third change was the biggest by a wide margin: about 760 lines across two coupon-admin pages. The before was string soup, <%= %> interpolation tangled into hand-written table and form HTML, driven by an ADO recordset loop with the usual .MoveNext() and .EOF lifecycle. We rewrote both pages into a fluent DOM builder on the page object, where each element returns this so the page reads as a tree, and swapped the recordset loop for a plain array query. Two correctness wins fell out for free. Every value now runs through an escape function instead of being dumped raw into HTML, and the empty state renders something instead of nothing.

By line count it was the heaviest change of the day. By blast radius it was the lightest. It’s an internal admin page with no user-visible behavior change, and a structural rewrite like that benefits from a second set of eyes before it touches a release. So it merged to the preview branch with no version tag, where it can sit and get looked at before riding out in the normal release.

Most people get this backwards. The instinct is to fast-track small changes and be careful with big ones. The four-line redirect fix was riskier to defer than the 760-line refactor, because the four-liner was actively throwing error screens at real users and the 760-liner wasn’t changing anything a customer could see. What matters is what happens in production if you’re wrong, and how fast you’d know.

The version tag carries the same signal. A tagged X.Y.Z hotfix means this touched production behavior, and here is the marker. No tag on a preview branch means it’s internal and goes through the normal gate. The lane and the version say the same thing.

The commit count that day

The raw activity number for the day looked impressive. It wasn’t.

The actual intellectual work was three changes: one redirect fix, one namespace-collision shim, one page refactor. The big commit count came from the topology. We run a fleet of clones, and every merge, tag, and back-merge replays across all of them. The same handful of merges and tags landing across a dozen places produces a number that has almost nothing to do with how much thinking happened. Commit count is a seductive metric and it lies in exactly this situation, because measuring anyone by commits in a multi-target setup rewards the topology, not the work. The measure that day was three.

What to ask first

Before deciding how a change ships, ask what it can break in production if it is wrong, and how fast you would know. That sent a four-line fix to a tagged hotfix and a 760-line internal refactor to the preview branch for the normal release.