If you've ever fixed a real bug inside a feature branch and then watched the fix wait weeks for the feature to merge, this is for you.
There is a failure mode that looks responsible but isn't: you find a real bug while building a feature, you fix it inside the feature branch, and then the whole thing waits for the feature to be reviewed and merged.
The bug is done. The fix works. But it can't ship, because it's now a passenger on a much bigger PR.
This happened in one of my open source projects, NeNe Vault, a self-hosted archive for received business documents. Here is what happened, the call I made, and the small mechanics of undoing it.
What you'll learn:
- How to spot when a bug fix has become a hostage to a feature's review timeline
- How to extract just the bug-fix subset and land it on
mainindependently - How to keep the git history honest about where an extracted fix came from
The setup
A feature PR was open to add an integrity-verified inline preview to the document detail page: render stored images and PDFs in the browser, re-compute their SHA-256, and show a "verified" badge only when the hash matches.
It was a substantial change: a new preview component with tests, a data-fetching hook, object-URL lifecycle handling, new locale keys for two languages, and a scope-contract entry. Exactly the kind of surface that takes real review time.
While building it, the author noticed the page's Download button was already broken — unrelated to the preview, a pre-existing defect. So they fixed it in the same branch and noted it in the PR description under "also fixes a pre-existing download bug."
That is a reasonable instinct. You're already in the file, you see the bug, you fix it. The problem is what happens next.
The bug that was trapped
The download bug was not cosmetic. The Download button failed for every user role. Confirmed in a real-browser walkthrough of the deployed demo, admin and viewer alike.
Two defects lived in the same handler:
-
No credentials. It built a plain
<a href>and clicked it. A plain link carries noAuthorizationheader, and this backend is JWT-only with no session cookie — so the request could never authenticate, on any hosting environment. -
Wrong key. The URL used the ordinal
version_number(1,2,3…), but the download route is keyed by the version's ULID. So even an authenticated request would have hit a 404.
The demo walkthrough had a highlighted step: "download the document and verify its SHA-256." With this bug, that step could not be performed at all. This was a production-facing defect on a page that's supposed to demonstrate trustworthiness.
And the fix for it was sitting, done, inside a feature PR that had been open for close to a month.
The call
The rule I try to hold to is simple:
A bug fix and a feature have different urgencies. Do not couple their release.
The feature can take its time in review — that's healthy. But the production bug should not wait on the feature's review just because they happen to share a branch. The moment they're coupled, the fix inherits the feature's schedule, its review scope, and its risk of stalling.
So the call was: extract the bug-fix subset, land it on main on its own, and let the feature keep cooking.
Concretely, that meant three artifacts instead of one:
- A bug issue describing only the download defect — root cause, reproduction, impact — and noting explicitly that the fix already exists inside the feature PR and is being extracted so it can land independently.
- A small, focused PR containing only the download fix.
- The original feature PR left open, to be rebased down to just the preview surface once the fix is on
main.
The mechanics
The important part of splitting a fix out is honesty about provenance. You are not pretending the fix was written fresh. You're relocating a known-good change to a smaller, faster vehicle and saying so.
The extracted PR carried exactly the download plumbing and nothing else:
- the authenticated blob-fetch method on the shared API client
- the entity-layer helper that targets the ULID-keyed download path
- the page change that resolves the version ULID from the history response and downloads through the authenticated client
Everything that belonged to the feature stayed in the feature PR: the preview component and its tests, the SHA-256 re-verification hook, the object-URL lifecycle for previews, the locale keys, and the scope-contract entry.
The core of the page-level fix was small. Before, condensed (the DOM-append boilerplate around the anchor click is elided in both snippets):
function handleDownload() {
if (doc === undefined) return;
const base = env.apiBaseUrl.replace(/\/$/, '');
// ordinal version_number + plain <a href> => 404 and no auth header
const url = `${base}/admin/vault/documents/${doc.id}/versions/${String(doc.version_number)}/download`;
const a = document.createElement('a');
a.href = url;
a.download = doc.original_filename ?? `document-${doc.id}`;
a.click();
}
After — resolve the real version ULID, fetch through the authenticated client, save from an object URL:
// The download endpoint is keyed by the version's ULID, which only the
// history response carries — the detail payload exposes just the ordinal.
const currentVersion =
doc !== undefined
? history?.versions.find((v) => v.version_number === doc.version_number)
: undefined;
async function handleDownload() {
if (doc === undefined || currentVersion === undefined) return;
const blob = await fetchDocumentBlob(doc.id, currentVersion.id);
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = doc.original_filename ?? `document-${doc.id}`;
a.click();
URL.revokeObjectURL(objectUrl);
}
Plus regression tests: at the entity level, that the request targets the ULID-keyed path and sends the auth headers; at the page level, that clicking Download issues a request keyed by the version ULID from the history response, not the ordinal 1.
The commit message recorded where the change came from, in words to this effect:
Extracted from the pending inline-preview feature PR, which first implemented this fix bundled with the preview feature; this lands the bug-fix subset on
mainindependently.
That one sentence keeps the history honest. Anyone reading git log later can see that the fix and the feature are two halves of the same original idea, deliberately separated.
How it actually went
The whole extraction, once decided, was fast. The bug issue was filed, the focused PR opened a few minutes later, and it merged a couple of minutes after that — closing the issue. The production download failure was fixed on main the same afternoon, while the feature PR stayed open.
The feature PR didn't get thrown away. A comment on it recorded exactly what had been lifted out and what remained, so the suggested next step was a straightforward rebase: most of the download plumbing was already on main, so slimming the feature down to the preview-only surface would be mostly deletions. The rebase-or-close decision was left to the PR's owner. The feature was never the thing in a hurry.
When coupling is actually fine
To be fair to the original instinct: bundling a fix into a feature branch is not always wrong.
If the bug and the feature touch the same code, the feature is small and about to merge, and nothing production-facing is broken in the meantime — then splitting them is just ceremony. Ship the branch.
The signal that you have a problem is specific:
- something production-facing is broken now, and
- the branch holding the fix is not close to merging.
When both are true, the fix has become a hostage. The longer the feature takes, the longer a known, solved defect stays live in production for no reason other than branch geography. That's the case worth acting on. The rest of the time, use judgment and don't over-process it.
There's also a cheap diagnostic. When you write the PR description and find yourself adding a line like "this also fixes an unrelated bug," treat that sentence as a small alarm. It's telling you the branch is carrying two changes with two different reasons to exist. Sometimes that's fine. Sometimes it's the thing you'll wish you'd split a month from now.
The takeaway
Finding a bug while you build a feature is normal and good. Fixing it in the same branch is the trap — not because the fix is wrong, but because you've quietly bound a production concern to a feature's timeline.
When it happens, don't argue about whether the feature should merge faster. Just split the fix out:
- File the bug on its own terms. Root cause, repro, impact — as if the feature didn't exist.
- Extract only the fix. Leave every line that belongs to the feature behind.
- Say where it came from. Provenance in the issue, the PR, and the commit message keeps the history readable.
- Let the feature keep its own pace. It was never the urgent part.
Two changes with different urgencies should have two release paths. If a production fix is stuck behind a feature review, the feature isn't the problem. The coupling is.
── I run a suite of self-hosted business tools in production. I take on consulting for legacy modernization and shared-hosting constraints. Contact: github.com/hideyukiMORI
Top comments (0)