Trunk-Based Development Actually Works — If You Do These 5 Things
The Branch That Lived Too Long
We audited a 30-person engineering team that was shipping once every two weeks and couldn't figure out why. The product team was frustrated. The CEO was frustrated. The engineers were frustrated.
We looked at their git history. They had 47 open feature branches. The oldest was 3 months old. Every merge was a multi-day event involving manual testing, conflict resolution, and prayer.
They had a branching strategy. It was called "everyone works in isolation and we figure it out later." And "later" was always painful.
Why Long-Lived Branches Fail
The math is simple. Merge conflict probability increases exponentially with:
- Number of files changed — More changes = more potential conflicts
- Duration of the branch — Longer branch = more main branch changes to conflict with
- Number of parallel branches — More branches = more things to conflict with each other
Branch Duration vs Merge Pain:
1 day branch: ~5% chance of conflict, ~10 min to resolve
1 week branch: ~30% chance of conflict, ~2 hours to resolve
2 week branch: ~60% chance of conflict, ~1 day to resolve
1 month branch: ~90% chance of conflict, ~3 days to resolve
3 month branch: ~99% chance of conflict, "we should just rewrite it"
Long-lived branches also create a hidden problem: integration risk. You don't know if your code works with everyone else's code until you merge. By that point, you've invested weeks of work into something that might not integrate cleanly.
What Trunk-Based Development Actually Is
Trunk-based development (TBD) means everyone commits to the main branch (trunk) frequently — at least once per day, ideally multiple times. There are no long-lived feature branches.
Traditional branching:
main ─────────────────────────────────────────────
\ /
feature/auth ──────────────────────────
\ /
feature/search ───────────
\ /
feature/checkout ──
Trunk-based development:
main ═══╦═══╦═══╦═══╦═══╦═══╦═══╦═══╦═══╦═══╦═══
│ │ │ │ │ │ │ │ │ │
A B A C B A B C A B
(commits from different developers, daily)
"But what about unfinished features?" Great question. That's where the five safety nets come in.
Safety Net 1: Feature Flags
Feature flags are the single most important enabler of trunk-based development. They let you merge incomplete code without exposing it to users:
// Feature flag wrapping an incomplete feature
export function SearchResults({ query }: { query: string }) {
const flags = useFeatureFlags();
if (flags.isEnabled("new-search-algorithm")) {
return <NewSearchResults query={query} />;
}
return <LegacySearchResults query={query} />;
}// Server-side feature flags for API changes
app.get("/api/search", async (req, res) => {
const flags = await getFlags(req.user);
if (flags.isEnabled("elasticsearch-backend")) {
const results = await elasticSearch.query(req.query.q);
return res.json(results);
}
// Legacy path — still works, always available
const results = await postgresSearch.query(req.query.q);
return res.json(results);
});Feature flags turn deployment into a non-event. You deploy code to production multiple times per day. You enable features when they're ready — a separate decision from a separate system.
Deploy ≠ Release
Deploy: Code goes to production (happens constantly, automated)
Release: Feature becomes visible to users (happens when ready, controlled)
Safety Net 2: Continuous Integration That Actually Works
CI is not "run the tests when someone opens a PR." In trunk-based development, CI runs on every commit to main. It must be fast, reliable, and comprehensive:
# .github/workflows/ci.yml
name: Trunk CI
on:
push:
branches: [main]
jobs:
fast-checks:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: npm ci --prefer-offline
- run: npm run typecheck # Must pass
- run: npm run lint # Must pass
- run: npm run test:unit # Must pass, must be fast (<2 min)
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: fast-checks
steps:
- uses: actions/checkout@v4
- run: npm ci --prefer-offline
- run: npm run test:integration
deploy-staging:
needs: [fast-checks, integration-tests]
runs-on: ubuntu-latest
steps:
- run: npm run deploy:stagingThe critical requirement: CI must finish in under 10 minutes. If it takes 30 minutes, developers will batch up changes to avoid waiting. Batching up changes defeats the entire purpose.
CI Speed Targets:
Lint + typecheck: < 1 minute
Unit tests: < 3 minutes
Integration tests: < 10 minutes
Full pipeline: < 15 minutes
## Safety Net 4: Automated Rollbacks
When you deploy to main multiple times per day, you need automatic rollback capabilities:
```typescript
// Deployment health check with automatic rollback
const deploymentConfig = {
strategy: "canary",
canaryPercent: 10, // Start with 10% of traffic
healthChecks: {
errorRate: { threshold: 0.5, window: "5m" }, // <0.5% error rate
latencyP99: { threshold: 500, window: "5m" }, // <500ms p99
successRate: { threshold: 99.5, window: "5m" }, // >99.5% success
},
promotionSteps: [10, 25, 50, 100], // Gradual rollout
rollbackOnFailure: true, // Automatic
};
If any health check fails, the deployment automatically rolls back to the previous version. No human intervention required. No 2am pages. The system protects itself.
Safety Net 5: Observability
You can't deploy confidently if you can't see what's happening. Every commit to main should be observable:
// Structured logging tied to deployments
const logger = createLogger({
defaultMeta: {
service: "api",
version: process.env.COMMIT_SHA,
deployedAt: process.env.DEPLOY_TIMESTAMP,
},
});
// Deployment markers in your monitoring
await datadog.createEvent({
title: `Deploy ${commitSha.substring(0, 7)}`,
text: `Deployed by ${deployer}. Changes: ${commitMessage}`,
tags: ["deployment", `env:${environment}`],
});When something breaks, you need to answer "which commit caused this?" in seconds, not hours.
The Results
That 30-person team with 47 feature branches? Here's what happened after adopting trunk-based development:
| Metric | Before (Feature Branches) | After (Trunk-Based) |
|---|---|---|
| Deploy frequency | Every 2 weeks | 4-8 times/day |
| Lead time (commit to production) | 2-3 weeks | 30 minutes |
| Merge conflicts per week | 12-15 | 1-2 |
| Rollbacks per month | 0 (couldn't rollback) | 3-4 (easy, fast) |
| Time spent on merge conflicts | 15 hours/week | 1 hour/week |
| Developer satisfaction | 3.2/10 | 7.8/10 |
The team went from shipping 2 features per sprint to shipping 8-10. Not because they worked harder — because they stopped wasting time on branching overhead.
When Not to Use Trunk-Based Development
TBD isn't right for every situation:
- Open source projects: External contributors need PRs and review before merge
- Heavily regulated industries: Some compliance frameworks require branch-based review workflows
- Very junior teams: If the team can't write atomic commits or use feature flags, start with short-lived branches (1-2 days max) and work toward TBD
But for most product engineering teams? Long-lived feature branches are a crutch. Trunk-based development is what shipping looks like when you do it right.
Stop branching. Start shipping.