Skip to content

Trunk-Based Development: Why Short-Lived Branches Win

How trunk-based development with short-lived branches reduces merge conflicts, speeds up delivery, and keeps the main branch always deployable.

4 min read
Diagram comparing long-lived feature branches with trunk-based short-lived branches

Long-lived feature branches feel safe. You work in isolation, take your time, avoid breaking anything. Then you try to merge a week later and spend half a day resolving conflicts. The branch diverged from main while you worked, and now the merge itself is the riskiest part of the change.

Trunk-based development inverts this. Everyone commits to the main branch (or merges short-lived branches within a day or two). Integration pain disappears because there is barely any divergence. The main branch is always deployable because every commit passes CI.

The Two Models

There are two variants of trunk-based development. Teams of up to a dozen or so can commit directly to trunk. Larger teams use short-lived branches that last one to two days maximum.

shbash
# ❌ Long-lived feature branches — the traditional model
main ─────────────────────────────────────────────
       \                                    /
        feature/auth-redesign ─────────────  (2 weeks)
                 \                  /
                  sub-branch ─────  (1 week)
 
# 3 weeks of divergence, massive merge, manual conflict resolution
shbash
# ✅ Trunk-based: short-lived branches merged within 1-2 days
main ──●──●──●──●──●──●──●──●──●──●──●──●──
       │     │        │  │     │        │
       └─●─┘ └──●──┘  └┘ └─●─┘ └──●──┘
 
# Each branch lives hours to 2 days max
# Small diffs, easy review, minimal conflicts

The key metric is integration frequency. Long-lived branches integrate once, after all the work is done. Trunk-based branches integrate continuously, in small increments.

Making Incomplete Work Safe

The biggest objection: "How do I commit incomplete features to main without breaking things?" Feature flags.

tstypescript
// Feature flag implementation — can be as simple as environment config
interface FeatureFlags {
  newCheckoutFlow: boolean;
  betaDashboard: boolean;
  experimentalSearch: boolean;
}
 
function getFeatureFlags(): FeatureFlags {
  return {
    newCheckoutFlow: process.env.FF_NEW_CHECKOUT === 'true',
    betaDashboard: process.env.FF_BETA_DASHBOARD === 'true',
    experimentalSearch: process.env.FF_EXPERIMENTAL_SEARCH === 'true',
  };
}
tsxtsx
// ❌ Keeping half-finished UI in a branch for weeks
// Meanwhile, 5 other developers change the same components
 
// ✅ Merging incomplete work behind a flag — code is on main, hidden from users
function CheckoutPage() {
  const flags = useFeatureFlags();
 
  if (flags.newCheckoutFlow) {
    return <NewCheckout />;  // Work in progress, only visible internally
  }
 
  return <CurrentCheckout />;  // Production users see this
}

The incomplete code ships to production but never executes for users. Developers continue building it incrementally, merging small changes daily. When the feature is ready, flip the flag.

Branch Protection and CI Gates

Trunk-based development requires a strong CI pipeline. Every merge must pass automated checks before hitting main.

ymlyaml
# .github/workflows/ci.yml — required checks for trunk
name: CI
on:
  pull_request:
    branches: [main]
 
jobs:
  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
 
      - name: Install dependencies
        run: npm ci
 
      - name: Type check
        run: npx tsc --noEmit
 
      - name: Lint
        run: npx eslint . --max-warnings 0
 
      - name: Unit tests
        run: npx jest --ci --coverage
 
      - name: Integration tests
        run: npx jest --ci --selectProjects integration
 
      - name: Build
        run: npm run build
ymlyaml
# Branch protection rules (configured in GitHub settings)
# Required for main branch:
#   ✓ Require pull request reviews (1 reviewer)
#   ✓ Require status checks to pass (quality-gate)
#   ✓ Require branches to be up to date before merging
#   ✓ Restrict who can push directly to main

With these gates, broken code cannot reach main. The main branch is always in a deployable state.

Small Pull Requests by Default

Trunk-based development does not mean merging large changesets faster. It means breaking work into smaller changesets that each make sense independently.

shbash
# ❌ One PR that does everything (800 lines changed)
feat: implement user settings page
- Add settings API endpoints
- Create settings form components
- Add form validation
- Update navigation
- Add settings E2E tests
- Update user model with preferences
 
# ✅ Multiple small PRs (each under 200 lines)
# PR 1: Add preferences field to user model + migration
# PR 2: Add GET/PUT /api/settings endpoints
# PR 3: Create SettingsForm component (behind feature flag)
# PR 4: Wire form to API, add validation
# PR 5: Add navigation link (flag-gated)
# PR 6: Enable feature flag, remove old code
shbash
# Stacked PRs workflow — each builds on the previous
git checkout main && git pull
git checkout -b settings/model     # PR 1: schema change
# ... commit, push, open PR
 
git checkout -b settings/api       # PR 2: built on model changes
# ... commit, push, open PR (base: settings/model)
 
git checkout -b settings/ui        # PR 3: built on API changes
# ... commit, push, open PR (base: settings/api)

Small PRs get reviewed faster. A 100-line diff gets meaningful feedback within hours. An 800-line diff sits in the queue for days and gets a rubber-stamp "LGTM."

Handling Merge Conflicts Proactively

Short-lived branches reduce conflicts, but they do not eliminate them. When two developers touch the same file, pull main frequently to catch conflicts early.

shbash
# Pull main into your branch at least once a day
git checkout my-branch
git fetch origin
git rebase origin/main
 
# If conflicts occur, they're small — just today's divergence
# Fix conflicts, then continue
git add .
git rebase --continue
shbash
# ❌ Two-week branch: 15 files conflicted, 3 hours to resolve
# ✅ One-day branch: 1 file conflicted, 5 minutes to resolve

Rebase instead of merge keeps the history linear. The main branch reads as a sequence of self-contained changes, not a spaghetti graph of merges.

Measuring Trunk-Based Success

Track these metrics to verify trunk-based development is working:

tstypescript
// Metrics that indicate healthy trunk-based development
interface TrunkMetrics {
  // How long a branch exists before merging
  branchLifespanHours: number;   // Target: < 48 hours
  
  // Lines changed per PR
  prSize: number;                // Target: < 200 lines
  
  // Time from PR open to merge
  reviewCycleHours: number;      // Target: < 8 hours
  
  // How often main is deployed
  deployFrequency: string;       // Target: daily or more
  
  // Percentage of main commits that pass CI
  greenBuildRate: number;        // Target: > 98%
  
  // Time to fix a broken main
  mainRecoveryMinutes: number;   // Target: < 30 minutes
}
shbash
# Quick check: branches older than 2 days
git for-each-ref --sort=-committerdate refs/remotes/origin \
  --format='%(committerdate:relative) %(refname:short)' \
  | head -20
 
# If you see branches older than 2 days, investigate why
# Common causes: blocked on review, scope too large, unclear requirements

A team doing trunk-based development well has a flat graph — main moves forward steadily with small, frequent commits. A team doing it poorly has a flat graph punctuated by occasional large merges that break the build.

The Transition Strategy

Teams on long-lived branches cannot switch overnight. Transition gradually:

  1. Week 1-2: Enforce a 5-day maximum branch lifetime. Break existing long branches into smaller chunks.
  2. Week 3-4: Reduce to 3-day maximum. Introduce feature flags for incomplete work.
  3. Week 5-6: Target 1-2 day branches. Require rebasing before merge.
  4. Week 7+: Measure metrics. Adjust review SLAs to support fast merge cycles.

The technical infrastructure (CI gates, feature flags, branch protection) should be in place before starting the transition. Without automated quality gates, commitments to main become risky.

Key Takeaways

  1. Short-lived branches (1-2 days) eliminate merge hell — small diffs, small conflicts, fast resolution
  2. Feature flags make incomplete work safe on main — code ships but does not execute until enabled
  3. CI gates protect main — automated tests, linting, and type checks run on every PR
  4. Small PRs get better reviews — under 200 lines gets meaningful feedback, 800 lines gets rubber-stamped
  5. Rebase daily from main — catching conflicts early means resolving one file, not fifteen
  6. Measure branch lifespan — if branches consistently exceed 48 hours, the work decomposition needs improvement
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX