A simple feature flags library with PostgreSQL integration, inspired by django-waffle.
- Multiple targeting strategies: Enable features for specific users, roles, groups, or percentage rollouts
- External subject targeting: Assign flags (or flag groups) to external subjects like tenants or org IDs
- Consistent hashing: Deterministic user bucketing for A/B testing and experimentation
- PostgreSQL-backed: Reliable, transactional flag storage with your existing database
- CLI included: Manage feature flags from the command line
- TypeScript-first: Full type safety with comprehensive TypeScript definitions
- Battle-tested: Comprehensive test suite with 31+ tests
- Node.js >= 18.0.0
- PostgreSQL >= 10.0
Install the library
npm install @brandtg/flapjackApply the migrations
import { runMigrations } from "@brandtg/flapjack";
await runMigrations({
// Connect to your PostgreSQL database
databaseUrl: process.env.DATABASE_URL,
// Use the same migrations table as your application
migrationsTable: "pgmigrations",
});import { Pool } from "pg";
import { FeatureFlagModel } from "@brandtg/flapjack";
// Connect to the database
const pool: Pool = getDatabasePool();
const featureFlags = new FeatureFlagModel(pool);
// Create a feature flag
// N.b. use a meaningful naming scheme like <feature>_<date>_<owner>
const flag = await featureFlags.create({
name: "enable_new_checkout_20250101_gbrandt",
});
// Check if a feature flag is active for a user
const active = await featureFlag.isActiveForUser({
name: "enable_new_checkout_20250101_gbrandt",
user: "1234",
});
// Assign a flag to an external subject ID (for example, a tenant)
await featureFlags.addSubject(flag.id, "tenant:acme");
// Check if active for a broader context that includes external subject IDs
const activeForTenant = await featureFlags.isActiveForContext({
name: "enable_new_checkout_20250101_gbrandt",
user: "1234",
subjects: ["tenant:acme"],
});
// Enable the feature flag for certain roles
await featureFlags.update(flag.id, {
roles: ["admin", "staff"],
});
// Enable the feature flag for certain user groups
await featureFlags.update(flag.id, {
groups: ["early_adopters"],
});
// Launch the flag to a certain percentage of users
await featureFlags.update(flag.id, {
percent: 25,
});
// Launch the flag to everyone
await featureFlags.update(flag.id, {
everyone: true,
});
// Disable the flag for everyone
await featureFlags.update(flag.id, {
everyone: false,
});
// Move the flag back into normal state (other rules then apply)
await featureFlags.update(flag.id, {
everyone: null,
});When checking if a feature flag is active for a user, Flapjack evaluates rules in the following order:
- Everyone Override (
everyone: true/false): If set, immediately returns this value, ignoring all other rules - User List (
users: [...]): If the user ID is in this list, returnstrue - Group Membership (
groups: [...]): If the user belongs to any specified group, returnstrue - External Subject Match (
subjects: [...]): If any provided subject matches a direct flag subject or a flag-group subject, returnstrue - Role Membership (
roles: [...]): If the user has any specified role, returnstrue - Percentage Rollout (
percent: 0-99.9): Uses consistent hashing to deterministically bucket users - Default: Returns
falseif no conditions are met
Use subjects when identity/group membership is managed outside Flapjack (for example, tenant IDs from another system).
const groupModel = new FeatureFlagGroupModel(pool);
const flagModel = new FeatureFlagModel(pool);
const rolloutGroup = await groupModel.create({
name: "checkout_rollout",
});
const checkoutFlag = await flagModel.create({
name: "enable_checkout_v2",
});
await groupModel.addFeatureFlag(rolloutGroup.id, checkoutFlag.id);
// Attach external subject to all flags in this feature flag group
await groupModel.addSubject(rolloutGroup.id, "tenant:acme");
// You can also attach a subject directly to a single flag
await flagModel.addSubject(checkoutFlag.id, "tenant:beta");
const isActive = await flagModel.isActiveForContext({
name: "enable_checkout_v2",
user: "user_123",
subjects: ["tenant:acme"],
});// Flag configured with multiple rules
await featureFlags.create({
name: "new_feature",
roles: ["admin"],
groups: ["beta_testers"],
percent: 25,
});
// Admin user: ✓ enabled (matches role)
await featureFlags.isActiveForUser({
name: "new_feature",
user: "user_123",
roles: ["admin"],
});
// Beta tester: ✓ enabled (matches group)
await featureFlags.isActiveForUser({
name: "new_feature",
user: "user_456",
groups: ["beta_testers"],
});
// Regular user: ? maybe (depends on hash bucket)
await featureFlags.isActiveForUser({
name: "new_feature",
user: "user_789",
roles: ["user"],
});Tags are freeform labels for organizing flags without encoding that information in the flag name. They do not affect evaluation — they are purely organizational metadata. A common use is associating a release version with a flag, which can then be bumped via a database update without any code change.
// Tag a flag with a release version (and any other labels)
await featureFlags.create({
name: "checkout_v2",
tags: ["release:1.5.0", "checkout"],
});
// Find every flag shipping in a given release
const flags = await featureFlags.listByTag("release:1.5.0");
// Bump the release version later — no code change required
await featureFlags.update(flag.id, { tags: ["release:1.6.0", "checkout"] });The equivalent CLI commands:
flapjack create --name checkout_v2 --tags release:1.5.0 checkout
flapjack list-by-tag release:1.5.0
flapjack update <id> --tags release:1.6.0 checkout
flapjack update <id> --clear-tagsisActiveForUser() call. For high-traffic applications, use the built-in caching layer:
Flapjack includes a high-performance caching layer with TTL support:
import { Pool } from "pg";
import {
FeatureFlagModel,
FeatureFlagCache,
InMemoryCache,
} from "@brandtg/flapjack";
// Set up the database model
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Create cached feature flags instance
const featureFlags = new FeatureFlagCache({
model: new FeatureFlagModel(pool),
cache: new InMemoryCache<boolean>(),
ttl: 300, // Set TTL to 5 minutes (300 seconds)
});
// Use exactly the same as the model, but with automatic caching
const isActive = await featureFlags.isActiveForUser({
name: "new_feature",
user: "user_123",
roles: ["admin"],
groups: ["beta_testers"],
});The cache automatically generates deterministic keys using MurmurHash3 of the flag name and user parameters:
// These calls generate the same cache key:
await featureFlags.isActiveForUser({
name: "test",
roles: ["admin", "user"],
groups: ["beta", "alpha"],
});
await featureFlags.isActiveForUser({
name: "test",
roles: ["user", "admin"], // Different order
groups: ["alpha", "beta"], // Different order
});You can implement your own cache (Redis, Memcached, etc.) by implementing the Cache interface:
import type { Cache } from "@brandtg/flapjack";
class RedisCache implements Cache {
private redis: RedisClient;
constructor(redis: RedisClient) {
this.redis = redis;
}
get(key: string): any {
const value = this.redis.get(key);
return value ? JSON.parse(value) : undefined;
}
set(key: string, value: any, ttl?: number): void {
const serialized = JSON.stringify(value);
if (ttl) {
this.redis.setex(key, ttl, serialized);
} else {
this.redis.set(key, serialized);
}
}
delete(key: string): void {
this.redis.del(key);
}
}
// Use your custom cache
const featureFlags = new FeatureFlagCache({
model,
cache: new RedisCache(redisClient),
ttl: 300,
});Always use connection pooling in production:
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
const featureFlags = new FeatureFlagModel(pool);- Cache flag configurations at the application level with a reasonable TTL (30-60 seconds)
- Batch flag checks when possible to reduce round trips
- Use database indexes: The default migration includes an index on
namefield - Monitor query performance: Feature flag checks should be <10ms in most cases
- Consider read replicas for extremely high-traffic scenarios
Flapjack's percentage rollout feature enables experimentation and A/B testing:
// Create an experiment: 50% of users see the new feature
await featureFlags.create({
name: "checkout_redesign_experiment",
percent: 50,
note: "A/B test: new checkout flow vs. old",
});
// Users are consistently bucketed - same result every time
const isInExperiment = await featureFlags.isActiveForUser({
name: "checkout_redesign_experiment",
user: "user_123",
});
// Gradual rollout: Start at 5%, increase to 100% over time
await featureFlags.update(flagId, { percent: 5 });
// ... monitor metrics ...
await featureFlags.update(flagId, { percent: 25 });
// ... monitor metrics ...
await featureFlags.update(flagId, { percent: 100 });- Uses MurmurHash3 for consistent, deterministic hashing
- Same user ID always maps to the same bucket (0-99)
- Changing the user ID will change bucket assignment
- Distribution is uniform across the user base
Creates a new feature flag.
Retrieves a feature flag by its ID. Archived flags are excluded unless includeArchived is true.
Retrieves a feature flag by its name. Archived flags are excluded unless includeArchived is true.
Lists all feature flags, ordered by ID. Archived flags are excluded unless includeArchived is true.
Lists all feature flags carrying the given tag (exact match), ordered by ID. Archived flags are excluded unless includeArchived is true.
Updates a feature flag. Returns the updated flag or null if not found.
Permanently deletes a feature flag and all its relationships. Returns true if deleted, false if not found. To hide a flag while keeping its history, use archive instead.
Archives (hides) a feature flag while keeping the row for historical/audit purposes. Archived flags are excluded from normal reads and evaluate as inactive, but remain retrievable via { includeArchived: true }. Returns the archived flag, or null if it does not exist or is already archived.
Archiving is irreversible (there is no unarchive) and the archived timestamp is immutable once set. The flag's name stays reserved permanently — a new flag cannot reuse it; create a new flag with a different name instead. The same archive(id) / includeArchived semantics apply to FeatureFlagGroupModel.
Checks if a feature flag is active for a user based on the evaluation rules.
Returns the hash value used for percentage bucketing. Useful for debugging rollout distributions.
Flapjack includes a CLI for managing feature flags:
# Set your database URL
export DATABASE_URL="postgresql://user:pass@localhost/dbname"
# Create a flag
flapjack create --name my_feature --roles admin --note "Admin-only feature"
# List all flags
flapjack list
# Include archived (hidden) flags in any read command
flapjack list --include-archived
flapjack get-by-name my_feature --include-archived
# List flags carrying a tag (e.g. a release version)
flapjack list-by-tag release:1.5.0
# Get a specific flag
flapjack get-by-name my_feature
# Check if active for a user
flapjack is-active my_feature --user user123 --roles admin
# Check if multiple flags are active for a user
flapjack are-active --names my_feature other_feature --user user123 --roles admin
# Check if active for context (with external subject IDs)
flapjack is-active-context my_feature --user user123 --subjects tenant:acme
# Check multiple flags for context
flapjack are-active-context --names my_feature other_feature --subjects tenant:acme
# Update a flag
flapjack update 1 --percent 50 --everyone false
# Clear specific fields
flapjack update 1 --clear-roles --clear-percent
# Delete a flag (permanent — destroys all metadata)
flapjack delete 1
# Archive a flag (hide it but keep its history; irreversible)
flapjack archive 1
# Add/remove/list subject mappings on a flag
flapjack add-subject 1 tenant:acme
flapjack remove-subject 1 tenant:acme
flapjack list-subjects 1
flapjack list-by-subject tenant:acme
# Feature flag groups
flapjack group-create --name checkout_rollout --note "Checkout launch cohort"
flapjack group-list
flapjack group-get 1
flapjack group-get-by-name checkout_rollout
flapjack group-update 1 --note "Updated"
flapjack group-delete 1
flapjack group-archive 1
# Group membership
flapjack group-add-flag 1 10
flapjack group-remove-flag 1 10
flapjack group-list-flags 1
flapjack group-list-for-flag 10
# Bulk update all flags in a group
flapjack group-update-all 1 --percent 25 --roles admin
# Group-level subject mappings
flapjack group-add-subject 1 tenant:acme
flapjack group-remove-subject 1 tenant:acme
flapjack group-list-subjects 1
flapjack group-list-by-subject tenant:acme
# Debug user bucketing
flapjack hash-user user123Use descriptive names that include context:
// Good: Includes feature, date, and owner
"enable_new_checkout_20250101_gbrandt";
"experiment_ai_suggestions_20250115_team_growth";
// Avoid: Too generic
"new_feature";
"test_flag";Always roll out features gradually:
- Start with internal users/roles (e.g.,
roles: ["admin", "staff"]) - Expand to beta testers (e.g.,
groups: ["beta_testers"]) - Percentage rollout (5% → 25% → 50% → 100%)
- Enable for everyone (e.g.,
everyone: true) - After stable, remove the flag from code and database
// 1. Development: Admin only
await featureFlags.create({
name: "new_feature_20250101",
roles: ["admin"],
note: "New feature in development",
});
// 2. Beta Testing
await featureFlags.update(flagId, {
groups: ["beta_testers"],
});
// 3. Gradual Rollout
await featureFlags.update(flagId, { percent: 10 });
// Monitor, then increase...
// 4. Full Launch
await featureFlags.update(flagId, { everyone: true });
// 5. Cleanup (after feature is stable)
// Remove feature flag checks from code, then either:
// - delete to remove it entirely, or
// - archive to hide it while keeping its history for audit
await featureFlags.archive(flagId);
// (archive is irreversible and the name stays reserved permanently)try {
const isActive = await featureFlags.isActiveForUser({
name: "my_feature",
user: "user_123",
});
if (isActive) {
// Show new feature
} else {
// Show old feature
}
} catch (error) {
// On error, fail closed (disable feature) or open (enable feature)
// depending on your risk tolerance
console.error("Feature flag check failed:", error);
const isActive = false; // Fail closed - safer default
}// Verify connection before using feature flags
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
try {
await pool.query("SELECT 1");
console.log("Database connection successful");
} catch (error) {
console.error("Database connection failed:", error);
}If isActiveForUser() returns false unexpectedly:
- Verify the flag exists:
flapjack get-by-name your_flag_name - Check the evaluation rules with
flapjack is-active - Verify user/role/group matching is case-sensitive
Debug user bucketing:
# Check which bucket a user falls into
flapjack hash-user user_123
# Output: { userId: "user_123", hash: 1234567, bucket: 67 }
# If bucket is 67 and percent is 50, user is NOT in rollout
# If bucket is 67 and percent is 75, user IS in rolloutInstall dependencies
npm installCreate an environment file:
npm run dev:envStart the PostgreSQL database:
npm run dev:docker:upRun database migrations:
npm run dev:migratenpm run testCreate a new migration:
npm run create-migration -- migration_nameReset the database:
npm run dev:docker:downTo build the project for development and testing:
npm run buildThis compiles TypeScript to JavaScript and generates type declaration files in the dist/ directory.
To create a tarball package that can be installed manually in other projects:
npm run pack:devThis will create a flapjack-0.1.0.tgz file that you can install in another project using:
npm install /path/to/flapjack-0.1.0.tgzBefore publishing to npm, make sure your package is ready:
-
Login to npm (one-time setup):
npm login
This will prompt for your username, password, email, and 2FA code.
-
Verify your login:
npm whoami
-
Update the version in
package.json:# For a patch release (0.1.0 -> 0.1.1) npm version patch # For a minor release (0.1.0 -> 0.2.0) npm version minor # For a major release (0.1.0 -> 1.0.0) npm version major
This automatically creates a git commit and tag.
-
Run pre-publish checks (linting, tests, and build):
npm run prepublishOnly
-
Publish to npm:
npm publish --access public
You'll be prompted for your 2FA code. For a dry run to see what would be published:
npm publish --access public --dry-run
-
Push the version tag to GitHub:
git push && git push --tags -
Optional: Create a GitHub release for the new version at https://github.com/brandtg/flapjack/releases/new
For pre-release versions:
# Update to a pre-release version
npm version prerelease --preid=beta
# Publish with beta tag
npm publish --access public --tag betaUsers can install beta versions with:
npm install @brandtg/flapjack@betaContributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the BSD 3-Clause License - see the LICENSE file for details.
This project is inspired by django-waffle.