Feature Flags for Startups: Ship Faster With Less Risk
Deployment and release are not the same thing. Deployment is putting code on the server. Release is making it available to users. Conflating the two is one of the most common mistakes we see in early-stage products, and it leads to a specific kind of pain: the “ship and pray” deploy where everything goes live at once, for everyone, with no way to undo it except rolling back the entire deployment.
Feature flags fix this. They let you deploy code without releasing it, release it gradually, and kill it instantly if something goes wrong. They are one of the highest-leverage tools a small team can adopt, and they are not complicated to implement.
Why decouple deployment from release
Consider a typical workflow without feature flags. You build a feature on a branch. You merge it. It deploys. Every user sees it immediately. If the feature has a bug, every user hits the bug. If it causes a performance regression, every user experiences it. Your options are to fix it in production under pressure or roll back the entire deployment — including any other changes that went out with it.
Now consider the same workflow with feature flags. You build the feature. You merge it behind a flag. It deploys, but no user sees it. You enable it for your team first and test in production. Then you roll it out to 5% of users and watch metrics. If everything looks good, you increase to 25%, then 50%, then 100%. If something looks wrong at any step, you flip the flag off. No rollback needed. No downtime. No panic.
We talked about this approach in our post on shipping fast without breaking things — feature flags are one of the core tools that make rapid iteration safe.

The basics of feature flags
A feature flag is a conditional that wraps a piece of functionality. At its simplest:
if (featureFlags.isEnabled('new-dashboard')) {
return <NewDashboard />;
}
return <OldDashboard />;
The flag value is determined at runtime, not at build time. It can be a simple boolean stored in a database, or it can be a complex rule that evaluates the current user, their plan, their geography, or a percentage rollout.
There are a few distinct types of flags, and understanding the differences matters.
Release flags
These control whether a feature is visible to users. They are temporary — once a feature is fully rolled out, the flag and the old code path should be removed. If you do not clean them up, you end up with a codebase full of dead conditionals that nobody is sure are safe to remove.
Ops flags
These are kill switches. They let you disable expensive or risky functionality without deploying new code. We add these to any feature that relies on an external service, runs a heavy computation, or is known to be fragile.
// Kill switch for an expensive third-party API
async function enrichUserProfile(userId: string) {
if (!featureFlags.isEnabled('profile-enrichment')) {
return null; // Gracefully degrade
}
try {
return await thirdPartyApi.enrich(userId);
} catch (error) {
// If the API is flaky, we can disable the flag
// instead of deploying a fix at 2 AM
logger.error('Profile enrichment failed', { userId, error });
return null;
}
}
Experiment flags
These support A/B testing. They assign users to variants deterministically (the same user always sees the same variant) and allow you to measure the impact of a change before committing to it.
Permission flags
These control access to features based on user attributes — plan tier, role, geography, beta program membership. These are often long-lived, unlike release flags.
Building a simple flag system
You do not need LaunchDarkly to get started. A database table and a few utility functions will take you surprisingly far.
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT false,
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
allowed_user_ids TEXT[] DEFAULT '{}',
description TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Seed some flags
INSERT INTO feature_flags (key, enabled, rollout_percentage, description) VALUES
('new-dashboard', true, 10, 'Redesigned dashboard with analytics widgets'),
('ai-suggestions', false, 0, 'AI-powered content suggestions'),
('bulk-export', true, 100, 'Bulk data export feature');
import { createClient } from '@supabase/supabase-js';
interface FeatureFlag {
key: string;
enabled: boolean;
rollout_percentage: number;
allowed_user_ids: string[];
}
class FeatureFlagService {
private flags: Map<string, FeatureFlag> = new Map();
private supabase;
constructor() {
this.supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
}
async loadFlags(): Promise<void> {
const { data, error } = await this.supabase
.from('feature_flags')
.select('*');
if (error) throw error;
this.flags = new Map(data.map((flag) => [flag.key, flag]));
}
isEnabled(key: string, userId?: string): boolean {
const flag = this.flags.get(key);
if (!flag) return false;
if (!flag.enabled) return false;
// Check explicit user allowlist first
if (userId && flag.allowed_user_ids.includes(userId)) {
return true;
}
// Percentage rollout using deterministic hashing
if (flag.rollout_percentage < 100 && userId) {
const hash = this.hashUser(key, userId);
return hash % 100 < flag.rollout_percentage;
}
return flag.rollout_percentage === 100;
}
private hashUser(flagKey: string, userId: string): number {
// Simple deterministic hash — same user always gets the same result
const str = `${flagKey}:${userId}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
}
// Usage
const flags = new FeatureFlagService();
await flags.loadFlags();
if (flags.isEnabled('new-dashboard', currentUser.id)) {
// Show new dashboard
}
This implementation gives you boolean flags, percentage rollouts with deterministic user assignment, and an explicit allowlist for internal testing. It is about 60 lines of code and covers 80% of what most startups need.
The deterministic hashing is important. If you use Math.random(), a user might see the new dashboard on one page load and the old one on the next. By hashing the flag key and user ID together, the same user always gets the same experience, and different flags can have different user assignments.
Gradual rollout in practice
Here is how we typically roll out a significant feature:
Step 1: Internal only. Add the team’s user IDs to the allowlist. Deploy. The team uses the feature in production with real data. This catches issues that staging misses — data edge cases, performance under real load, integration quirks.
Step 2: 5% of users. Enable the percentage rollout. Watch error rates, performance metrics, and support tickets. Leave it at 5% for a day or two.
Step 3: 25% of users. If step 2 looks clean, increase. This is where you start getting statistically meaningful feedback on engagement and usability.
Step 4: 50% of users. At this point you can run a meaningful A/B test if the feature has measurable success metrics.
Step 5: 100% of users. Full rollout. Monitor for another week.
Step 6: Clean up. Remove the flag, remove the old code path, remove the database row. This step is easy to skip and important not to. Stale flags are their own form of technical debt.

Using feature flags for A/B testing
A/B testing with feature flags does not require a separate experimentation platform. You just need the flag system to assign users to variants and an analytics system to measure outcomes.
type Variant = 'control' | 'variant_a' | 'variant_b';
function getVariant(flagKey: string, userId: string): Variant {
const hash = hashUser(flagKey, userId);
const bucket = hash % 100;
// 34% control, 33% variant A, 33% variant B
if (bucket < 34) return 'control';
if (bucket < 67) return 'variant_a';
return 'variant_b';
}
// In your component
const variant = getVariant('pricing-page-layout', user.id);
switch (variant) {
case 'control':
return <PricingPageCurrent />;
case 'variant_a':
return <PricingPageSimplified />;
case 'variant_b':
return <PricingPageWithComparison />;
}
// Track the variant in analytics
analytics.track('pricing_page_viewed', {
variant,
userId: user.id,
});
The key is tracking which variant each user saw alongside their conversion events. Then you can query your analytics data to compare conversion rates across variants.
When to use a third-party service
The custom approach above works well for small teams. But as your flag usage grows, third-party services earn their cost. Here is when we recommend upgrading:
You have more than 20 active flags. Managing them in a database table gets unwieldy. You need a UI, audit logs, and environment management.
Multiple teams need to manage flags. When product managers need to control rollouts without asking an engineer to update a database row, a dedicated UI matters.
You need targeting rules beyond percentage and user ID. Geography, plan tier, device type, custom attributes — building this yourself is a rabbit hole.
You need real-time flag evaluation. If flags need to change and take effect within seconds (not minutes), you need a service with streaming or websocket-based delivery.
The tools we have used and can recommend:
- LaunchDarkly — the most mature option. Excellent SDKs, real-time updates, strong targeting rules. Expensive for startups.
- Flagsmith — open source, self-hostable. Good balance of features and cost. We have used this on projects where data residency matters.
- PostHog feature flags — if you already use PostHog for analytics, the built-in feature flags are solid and the experimentation integration is seamless.
- Vercel Edge Config — if you are on Vercel, this gives you ultra-fast flag evaluation at the edge. Limited targeting but great for simple flags.
Common mistakes
Not cleaning up old flags
The most common mistake. Once a feature is at 100% and stable, remove the flag. Schedule it. Put it in the sprint. If you do not, you will end up with hundreds of flags, half of which nobody knows whether they are safe to remove.
We add a sunset_date column to our flag table and run a weekly query for flags past their sunset date that are still active. It is a small thing that prevents a big mess.
-- Find flags that should have been cleaned up
SELECT key, description, rollout_percentage, sunset_date
FROM feature_flags
WHERE sunset_date < now()
AND rollout_percentage = 100
ORDER BY sunset_date ASC;
Testing only the happy path
When you add a feature flag, you have two code paths. Both need to work. Write tests that exercise both the flag-on and flag-off paths, and make sure the off path degrades gracefully.
describe('Dashboard', () => {
it('renders new dashboard when flag is enabled', () => {
mockFlags({ 'new-dashboard': true });
render(<Dashboard userId="user-1" />);
expect(screen.getByTestId('new-dashboard')).toBeInTheDocument();
});
it('renders old dashboard when flag is disabled', () => {
mockFlags({ 'new-dashboard': false });
render(<Dashboard userId="user-1" />);
expect(screen.getByTestId('old-dashboard')).toBeInTheDocument();
});
});
Nesting flags
If flag A depends on flag B, you have created a combinatorial explosion of states. Keep flags independent. If two features are related, use one flag with multiple variants instead of two boolean flags.
Using flags for permanent configuration
Feature flags are for temporary states — rolling out, testing, killing. If something is a permanent configuration (like “enable dark mode” or “show admin panel”), use a proper configuration or permissions system, not a feature flag.

Start simple, scale later
If you are a startup shipping your first product, you do not need a feature flag platform. You need a database table, a utility function, and the discipline to use it. That gets you 80% of the value — safe deployments, gradual rollouts, kill switches — for essentially zero cost.
As you grow, as the number of flags increases, as more people need to manage them, upgrade to a dedicated service. But do not let the complexity of the “right” solution stop you from getting the basics in place today.
The goal is not feature flags. The goal is shipping fast without breaking things. Feature flags are one of the best tools for getting there.
If you are building a product and want to set up a deployment workflow that lets you ship with confidence, reach out at hello@threshline.com.