Trunk-Based Development vs GitFlow: Choosing a Branching Strategy That Ships
Most teams pick a branching strategy based on what they used at their last job. This guide compares trunk-based development and GitFlow head-to-head, covering tradeoffs for team size, release cadence, CI/CD maturity, and feature flag infrastructure, with TypeScript CI pipeline examples and a decision framework for startup teams.
Most teams pick a branching strategy the same way they pick a code editor: whatever they used at their last job. GitFlow is familiar because a previous lead set it up. Trunk-based development shows up in a conference talk and sounds modern. Neither choice gets interrogated against the actual constraints of the current team.
That’s a problem. Your branching strategy is not just a Git convention. It directly controls how fast you can merge, how confident you are in main, how much merge conflict pain your team absorbs, and whether your CI/CD pipeline can do its job. The wrong strategy for your context creates real cost: slow feature delivery, long-lived branches that rot, coordination overhead on every release, or production incidents from under-tested code.
This guide compares the two dominant approaches, shows you what the pipelines actually look like in TypeScript, and gives you a decision framework based on your team’s current reality.
The Core Difference
The mental models diverge at one question: how long should a branch live?
GitFlow says: branches live as long as the feature, release, or hotfix needs them. Feature branches can last days or weeks. Release branches exist until a version ships and is patched.
Trunk-based development says: branches should live for hours, not days. Developers integrate to main (the trunk) continuously. Long-lived branches are a smell.
Everything else follows from this.
GitFlow in Detail
GitFlow, introduced by Vincent Driessen in 2010, defines a fixed set of branch types with specific roles:
main Production code. Tagged at each release.
develop Integration branch. All features merge here.
feature/* One per feature. Branches from develop.
release/* Stabilization. Branches from develop, merges to main + develop.
hotfix/* Emergency fix. Branches from main, merges to main + develop.
A typical feature lifecycle:
develop
└── feature/payment-retry
(work happens over 1-2 weeks)
└── merge back to develop
└── release/2.4.0 (QA, bugfixes)
├── merge to main (tag v2.4.0)
└── merge back to develop
The appeal is clear: explicit separation between integration work and production code. QA gets a stable target in the release branch. Hotfixes have a clean path to production without pulling in half-finished features from develop.
A GitHub Actions pipeline in a GitFlow repo typically looks like this:
// .github/workflows/ci.yml (simplified TypeScript representation of the config logic)
const gitflowPipeline = {
triggers: {
"feature/*": ["lint", "unit-tests"],
develop: ["lint", "unit-tests", "integration-tests", "deploy-staging"],
"release/*": ["lint", "unit-tests", "integration-tests", "e2e-tests", "deploy-uat"],
main: ["lint", "unit-tests", "integration-tests", "e2e-tests", "deploy-production"],
"hotfix/*": ["lint", "unit-tests", "deploy-hotfix-staging"],
},
};
Each branch type maps to a different pipeline depth. Feature branches get fast feedback. Main gets the full suite.
Trunk-Based Development in Detail
In trunk-based development, main is always releasable. Developers either commit directly to main (on very small teams) or use short-lived feature branches that merge within a day or two at most.
main ──────────────────────────────────────────────────────────────→
↑ ↑ ↑ ↑ ↑
commit merge/PR merge/PR merge/PR merge/PR
(direct) (2hrs) (4hrs) (1day) (3hrs)
Release is not a branch event. You tag a commit on main, or you deploy main directly. If you need to support multiple production versions (a SaaS with enterprise customers on older releases), you cut a short-lived release branch from main and cherry-pick critical fixes to it.
A trunk-based pipeline enforces that main is always green:
// Trunk-based CI logic
const trunkPipeline = {
triggers: {
// Short-lived feature branches: fast feedback only
"feat/*": ["lint", "unit-tests", "integration-tests"],
// Every merge to main runs everything
main: [
"lint",
"unit-tests",
"integration-tests",
"e2e-tests",
"security-scan",
"deploy-production",
],
},
// Main is never broken. Failing main pages the team.
branchProtection: {
main: {
requiredChecks: ["lint", "unit-tests", "integration-tests"],
requiredReviewers: 1,
enforceAdmins: true,
},
},
};
The critical enabler: feature flags. When every merge goes to production, you need a way to ship incomplete features without activating them for users. Feature flags decouple deployment from release.
// Simple feature flag check in production code
import { getFlag } from "@/lib/flags";
export async function getCheckoutFlow(userId: string) {
const useNewPaymentRetry = await getFlag("new-payment-retry", { userId });
if (useNewPaymentRetry) {
return newCheckoutWithRetry(userId);
}
return legacyCheckout(userId);
}
The flag infrastructure can be as simple as environment variables on a small team or as sophisticated as a LaunchDarkly/Growthbook integration on a larger one. The point is that the code ships, but the behavior is gated.
Tradeoffs Table
| Dimension | GitFlow | Trunk-Based |
|---|---|---|
| Branch lifetime | Days to weeks | Hours to a day |
| Merge conflicts | High (long-lived branches diverge) | Low (frequent integration) |
| Prod stability | High (explicit release gate) | High (if CI is enforced) |
| Release flexibility | Structured scheduled releases | Continuous or on-demand |
| CI/CD complexity | Moderate (multiple branch pipelines) | Low (one branch to rule them) |
| Feature flag requirement | Optional | Required for incomplete features |
| Team size fit | 5-50 engineers with distinct roles | 1-20 engineers moving fast |
| Onboarding complexity | High (6 branch types to explain) | Low (commit to main or short PR) |
| Rollback strategy | Tag + revert release branch | Feature flag off + deploy |
| Open source suitability | High (external contributors need isolation) | Low (external contributors lack flag access) |
When GitFlow Actually Makes Sense
GitFlow gets a bad reputation from teams that adopted it when trunk-based development would have served them better. But there are contexts where it fits:
Versioned software with multiple active releases. If you ship v2.x and v3.x simultaneously (enterprise software, open-source libraries, mobile SDKs), you need long-lived release branches. Trunk-based development does not have a clean answer for this.
Regulated environments with formal QA gates. If your release process requires a signed-off UAT cycle before production, a release branch gives QA a stable target while development continues on develop. The alternative in trunk-based development, freezing main for QA windows, defeats the point.
Teams with external contributors. Open source projects cannot assume that all contributors have access to your feature flag system. Long-lived feature branches from forks are the standard model for a reason.
Teams that do not yet have CI/CD. This is a short-term reason, not a long-term one. But if you have no automated tests and no deployment pipeline, GitFlow’s release branch at least gives you a manual quality gate. Trunk-based development without CI is just committing broken code to production.
When Trunk-Based Development Wins
Continuous deployment to a single production target. SaaS products, web apps, APIs. You have one production environment, you deploy frequently, and every engineer can see the result of their merge within an hour. This is the native habitat of trunk-based development.
Small teams where coordination overhead compounds. On a team of four engineers, managing a develop branch, multiple feature/* branches, and a release/* branch creates cognitive load disproportionate to the value. Trunk-based development removes the ceremony.
Mature CI/CD with high test coverage. When your test suite runs in under ten minutes and catches real bugs, trusting main is not optimistic. It is earned. The pipeline is your quality gate.
Fast iteration cycles. Startups that need to run experiments, roll back quickly, and ship multiple times per day cannot afford week-long feature branches. The feedback loop from code to production intelligence collapses with trunk-based development.
The Feature Flag Infrastructure Question
The biggest practical obstacle to trunk-based development is feature flag infrastructure. Teams that skip this end up with one of two failure modes:
- They merge incomplete features to
mainwithout flags, and production users see broken flows. - They let branches grow long “just until the feature is done,” which is GitFlow with extra steps.
A minimal feature flag system does not need to be complex. Here is a production-ready implementation using a simple key-value store:
// lib/flags.ts
type FlagContext = {
userId?: string;
orgId?: string;
env?: string;
};
type FlagRule = {
enabled: boolean;
allowlist?: string[]; // userIds or orgIds
rolloutPercent?: number; // 0-100
};
const flags: Record<string, FlagRule> = {
"new-payment-retry": {
enabled: true,
rolloutPercent: 10, // 10% of users
},
"redesigned-checkout": {
enabled: true,
allowlist: ["user_alpha_tester_001", "user_alpha_tester_002"],
},
"ai-suggestions": {
enabled: false, // dark deploy, no one sees it yet
},
};
export async function getFlag(
key: string,
ctx: FlagContext = {}
): Promise<boolean> {
const rule = flags[key];
if (!rule || !rule.enabled) return false;
// Allowlist check
if (rule.allowlist && ctx.userId) {
return rule.allowlist.includes(ctx.userId);
}
// Percentage rollout using stable hash
if (rule.rolloutPercent !== undefined && ctx.userId) {
const hash = stableHash(`${key}:${ctx.userId}`);
return hash % 100 < rule.rolloutPercent;
}
return rule.enabled;
}
function stableHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
This is the floor. For production usage, you’ll want flags stored in a database (so you can change them without a deploy), an admin UI, and logging to track exposure. But the basic pattern is the same whether you build it yourself or use a managed service.
The operational commitment matters more than the implementation. Trunk-based development requires that your team has a culture of flagging incomplete work before merging, not just a tool.
A Decision Framework
Answer these questions in order:
1. Do you ship multiple simultaneous release versions? Yes: lean toward GitFlow or a variation with long-lived release branches. No: continue.
2. Do you have a regulated QA process that requires a manual sign-off window? Yes: GitFlow release branches give that window cleanly. No: continue.
3. Do you deploy to production more than once a week? No: either strategy works. Pick the one your team already knows. Yes: trunk-based development fits your cadence; GitFlow will create friction.
4. Is your CI pipeline automated, covering unit and integration tests? No: fix this first. Neither strategy works well without a test suite. Yes: continue.
5. Can you build or integrate basic feature flag infrastructure in a sprint? No: this is a real blocker. Plan for it. Yes: trunk-based development is available to you.
Most startup engineering teams end up here: deploying frequently, no multiple-version requirement, reasonable test coverage. The answer is trunk-based development. The remaining investment is feature flags and enforcing branch protection on main.
Production Considerations
A few patterns that matter once you commit to one approach:
Enforcing trunk-based discipline. Branch age limits help. In your CI, fail a PR that has been open longer than 48 hours without a merge or close. This sounds harsh, but it forces decomposition: if a feature branch needs three days, the feature is too big to ship as one unit.
// GitHub Actions step to check branch age
const checkBranchAge = async (branchName: string): Promise<void> => {
const { execSync } = await import("child_process");
const created = execSync(
`git log --reverse --format="%ai" origin/${branchName} | head -1`
)
.toString()
.trim();
const ageMs = Date.now() - new Date(created).getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays > 2) {
console.error(
`Branch ${branchName} is ${ageDays.toFixed(1)} days old. Trunk-based branches should merge within 2 days.`
);
process.exit(1);
}
};
GitFlow release branch discipline. The most common GitFlow failure mode is a release branch that never closes. Code accumulates, hotfixes get applied only to main but not back-merged to develop, and the two branches permanently diverge. Enforce a release branch TTL in your team’s process. A release branch older than two weeks without shipping is a red flag.
Protecting trunk. In trunk-based development, a broken main is a five-alarm incident. Enforce required status checks and require at least one reviewer on all PRs. On small teams (two to three engineers), the overhead is low and the safety is real.
The Migration Path
If you’re on GitFlow and want to move to trunk-based development, the migration is not a big-bang switch. A practical path:
- Stop creating new
feature/*branches. Move to short-lived branches offmainthat merge within a day. - Collapse
developintomainonce all in-flight features land. Deletedevelop. - Replace release branches with tagged commits on
main. Your deploy pipeline releases from a tag. - Ship feature flag infrastructure for the next major feature work.
- After 90 days with no long-lived branches, you’re there.
The hardest part is the culture shift, not the tooling. Engineers who are used to develop as a safety buffer will feel exposed. The answer is higher test coverage and faster CI feedback, not longer-lived branches.
The Real Question
GitFlow and trunk-based development are not philosophically opposed. They make different bets about where the risk lives. GitFlow bets that explicit gates and branch isolation reduce production risk. Trunk-based development bets that frequent integration and automated testing reduce it faster.
For a team shipping a SaaS product to a single production environment with CI that runs in under ten minutes, trunk-based development wins the bet consistently. For a team shipping versioned enterprise software with formal QA requirements, GitFlow wins.
The mistake is not picking the wrong strategy. The mistake is picking without asking which bet you’re actually making.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.