Widening an Unleash rollout should keep the people already in
Before widening a feature flag, I want to know where the people already using it will land. I checked that with the real Unleash Node SDK and a small local configuration fixture. Of 1,000 synthetic users, 109 were enabl
Before widening a feature flag, I want to know where the people already using it will land.
I checked that with the real Unleash Node SDK and a small local configuration fixture. Of 1,000 synthetic users, 109 were enabled by the 10% rule and 504 by the 50% rule. Every person in the smaller cohort stayed in the larger one.
The fresh Unleash anniversary discussion is a useful reason to inspect this everyday behavior. [1] This is an established feature-management tool, not a newly launched package. My check uses unleash-client 6.12.2 on Node 22.22.1.
Actual SDK decisions on synthetic identities. The dot panel shows a subset; the counts use all 1,000 identities. Image by the author.
Give the check a small world
I used an HTTP fixture that serves feature definitions on loopback. The official SDK fetches those definitions and evaluates them. There is no hosted account, production user data or running Unleash server in this example.
You need Node 20 or later, as required by this SDK version. In a new directory, run:
npm init -y
npm install --save-exact [email protected]
Save this as rollout.mjs, then run node rollout.mjs:
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
import { startUnleash, InMemStorageProvider } from 'unleash-client';
const flag = (name, rollout, groupId = 'checkout') => ({
name, enabled: true,
strategies: [{ name: 'flexibleRollout', parameters: {
rollout: String(rollout), stickiness: 'userId', groupId,
} }],
});
const features = [
flag('ten', 10), flag('fifty', 50), flag('renamed', 10),
flag('other-group', 10, 'different'), flag('zero', 0), flag('all', 100),
];
const requests = [];
const server = createServer((req, res) => {
requests.push(req.url);
if (req.url !== '/api/client/features') {
res.writeHead(404).end(); return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ version: 2, features }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
const client = await startUnleash({
url: `http://127.0.0.1:${port}/api/`, appName: 'rollout-lab',
disableMetrics: true, refreshInterval: 60000,
storageProvider: new InMemStorageProvider(),
});
try {
const users = Array.from({ length: 1000 }, (_, i) => `user-${i}`);
const rows = users.map(userId => ({ userId,
ten: client.isEnabled('ten', { userId }),
fifty: client.isEnabled('fifty', { userId }),
renamed: client.isEnabled('renamed', { userId }),
other: client.isEnabled('other-group', { userId }),
}));
for (const row of rows) {
assert.equal(client.isEnabled('ten', { userId: row.userId }), row.ten);
assert(!row.ten || row.fifty);
assert.equal(row.ten, row.renamed);
assert.equal(client.isEnabled('zero', { userId: row.userId }), false);
assert.equal(client.isEnabled('all', { userId: row.userId }), true);
}
assert.equal(client.isEnabled('ten', {}), false);
assert.equal(client.isEnabled('missing', { userId: 'user-1' }), false);
const count = key => rows.filter(row => row[key]).length;
const summary = { users: rows.length, ten: count('ten'), fifty: count('fifty'),
lostWhenWidened: rows.filter(row => row.ten && !row.fifty).length,
changedAfterRename: rows.filter(row => row.ten !== row.renamed).length,
changedAfterGroup: rows.filter(row => row.ten !== row.other).length,
missingUserId: client.isEnabled('ten', {}), requests };
writeFileSync('membership.json', JSON.stringify(rows, null, 2) + '\n');
console.log(JSON.stringify(summary, null, 2));
} finally {
client.destroy();
await new Promise(resolve => server.close(resolve));
}
The fixture uses an automatically assigned local port. Metrics are disabled, storage is in memory, and both the client and fixture server are stopped at the end.
My output was:
{
"users": 1000,
"ten": 109,
"fifty": 504,
"lostWhenWidened": 0,
"changedAfterRename": 0,
"changedAfterGroup": 187,
"missingUserId": false,
"requests": [
"/api/client/features"
]
}
The program also writes membership.json, containing only the generated identities and their flag decisions. I separately checked that file with Python to verify the counts and cohort relationships.
Keep the identity of the experiment stable
Each strategy explicitly uses userId for stickiness and checkout for its group ID. The SDK turns that combination into a stable bucket and compares it with the rollout threshold. [2]
That explains the nesting: changing only the percentage moves the threshold while keeping each person's bucket fixed.
The renamed flag in the fixture uses the same group ID and percentage. Its decisions matched the original for every tested identity. Changing the group ID to different changed membership for 187 identities in this cohort. It effectively asks a different bucketing question.
Adrian Turbinski's sticky-rollout walkthrough makes the same design principle explicit in a separate evaluator: keep the rollout weight out of the hash input. [3] That article's implementation is not the Unleash algorithm used here.
The observed counts are also a useful reminder: a nominal 10% rollout selected 109 of these 1,000 IDs. Hash-based assignment does not promise an exact quota for every finite population.
Fetch once before inspecting membership
My first fixture used refreshInterval: 0, intending to avoid background polling. In this SDK version, that also prevents the initial fetch. startUnleash() waited for synchronization that never happened.
The corrected example uses 60,000 milliseconds, allows the first fetch, and destroys the client after the checks. I inspected the published polling implementation to confirm that zero exits before making a request. [4] A positive refresh interval is a setting here, not a measured timing result.
There is another deliberate boundary in the fixture: an explicit userId strategy with no user ID returns false. The default stickiness mode has its own fallback behavior; don't assume it has the same missing-identity contract. [2]
This checks membership behavior in one pinned Node SDK on stated synthetic inputs. It does not test a production rollout, cross-language agreement or whether the feature itself works. Those application checks still belong beside the flag.
References
- ivarconr, We've built an open-source feature flag server for 11 years.
- Unleash contributors, flexible rollout strategy at the package's source revision, and the Node SDK.
- Adrian Turbinski, Build sticky percentage rollouts with MurmurHash3 in TypeScript.
- Unleash contributors, polling implementation at the same source revision.
Asset credits
Original membership plot derived from the executed SDK decisions. The fixture and generated identities are original examples; the SDK retains its Apache-2.0 licence.
AI disclosure
AI was used to draft the article.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.