Sticky bucketing
Sticky bucketing remembers a user's variation (one specific version being tested, control or a challenger) assignment. An edit to the rule mid-experiment can never bump someone who is already in it into a different variation. Once a user is placed, they stay there for the life of the rule.
What it is
By default, A vs B uses deterministic hashing: given the same user key, flag key, and rule, a user always lands in the same bucket (sorting a visitor into a group by a consistent hash of their id, so the same visitor always lands in the same group). As long as the rule itself does not change, this alone makes assignments stable.
But a rule can change under a user while an experiment runs. Say a variation split changes: 50/50 becomes 30/70. Or an audience (a named, reusable group of visitors defined by targeting rules) condition gets refined. Or rules get reordered. Any of these can move a user already in the experiment into a different variation, purely from the edit. One change is always safe by itself: growing how much traffic a rule gets. That only ever adds new users; it never reshuffles anyone already inside.
Sticky bucketing protects against every other kind of change. When it is on, the SDK records each user's first assignment. It re-uses that assignment on every later evaluation, no matter what the rule looks like by then.
A worked example
Say your checkout experiment splits traffic 50/50 between the old and new flow. After a week the new flow is clearly winning, so you shift the split to 30% old, 70% new, to get a faster read on the winner.
Without sticky bucketing, some users already in the experiment would silently flip from the old flow to the new one mid-session. These are the ones whose hash sits between the old and new thresholds, right as you are trying to read a clean result. With sticky bucketing on, everyone already assigned keeps what they were first given. Only newly-arriving users see the new 30/70 split.
When to use it
Enable sticky bucketing when any of these apply:
- You expect to edit the rule while it runs. A changed variation split, a refined audience condition, or a reordered rule can each change which variation a user gets. Sticky bucketing is what protects a user already in the experiment from all three.
- The experiment involves irreversible actions. Checkout flows, onboarding sequences, and pricing pages are all cases where switching mid-stream would confuse users or corrupt your analysis.
- Your audience conditions might change. You refine a targeting segment after launch, and you need the users who already qualified to keep their variation.
Assignments are stored for ab_test rules only. A rollout is not an assignment, a holdout is not a decision worth remembering, and a bandit is meant to keep re-deciding as it learns. A stored assignment is still replayed ahead of all of them.
How it works
The SDK stores a StickyAssignment record keyed by user and flag:
import type { RuleType } from '@avsbhq/core'interface StickyAssignmentShape { /** The assigned variation. */ variationId: string /** Which rule produced the assignment. */ ruleId: string ruleType: RuleType /** Milliseconds since the epoch, when it was assigned. */ assignedAt: number}When the evaluator finds a cached assignment, it checks only that the assigned variation still exists on the flag. If it does, the SDK serves that value right away. It does this on purpose, even if the rule that produced it has since been deleted or edited: a stored assignment is designed to survive a rule edit, not be undone by one.
Two things follow from that. If the assigned variation has been removed from the flag entirely, the lookup misses. The SDK then falls back to normal rule evaluation for that read. Separately: the SDK may no longer find the original rule, or that rule may no longer offer the assigned variation. When that happens, it still serves the sticky value, but it logs a warning and skips recording an exposure (the moment a visitor is actually counted in an experiment) for that read. A rule you already deleted does not go on collecting results.
Where assignments are stored
Sticky bucketing is a server-side capability. You pass the store to the server SDK as stickyBucketService, and its read path is synchronous, because evaluation itself is synchronous. The browser client has no sticky store, and no option that takes one: in the browser, bucketing is stable because the visitor id is stable.
| Store | Where it lives | Use it for |
|---|---|---|
InMemoryStickyBucketService | @avsbhq/node | A single process, and tests. |
RedisStickyBucketService | @avsbhq/node | Multi-process, multi-region deployments. |
createDynamoDBStickyBucketService | @avsbhq/utils | Serverless and Lambda estates already on DynamoDB. |
createPostgresStickyBucketService | @avsbhq/utils | Reusing your Postgres with one small sidecar table. |
createDurableObjectStickyBucketService | @avsbhq/utils | Cloudflare Workers, one Durable Object per shard of users. |
Per-SDK usage
The same operation, constructing a server with a sticky store, in three languages:
import { AvsbServer, RedisStickyBucketService } from '@avsbhq/node'import { createClient } from 'redis'const redis = createClient({ url: process.env.REDIS_URL })await redis.connect()const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '', stickyBucketService: new RedisStickyBucketService({ redis, prefix: 'avsb:sticky:' }),})await server.onReady()import osimport redisfrom avsb import AvsbServerfrom avsb.sticky.redis import RedisStickyBucketServiceserver = AvsbServer( sdk_key=os.environ["AVSB_SDK_KEY"], sticky_bucket_service=RedisStickyBucketService(redis.Redis()),)import ( "os" "github.com/avsbhq/avsb-go" "github.com/avsbhq/avsb-go/sticky")server, _ := avsb.NewServer(avsb.ServerOptions{ SDKKey: os.Getenv("AVSB_SDK_KEY"), StickyBucketService: sticky.NewInMemoryService(),})The Redis service (TypeScript sample above) keeps evaluation synchronous. A lookup is either a cache hit or a null, with a background fill, so the first lookup per process misses by design. A store outage degrades bucketing stability and says so in the log. It never breaks an evaluation.
Bringing your own store is two methods, lookup and save, in every SDK.
Previewing a flag in an admin panel? Use a per-request forced decision:
server.forUser(ctx).setForcedDecision('checkout_v2', { variationKey: 'on' }).
It takes precedence over the sticky store for that request only, and pairing it
with DecideOption.DISABLE_EXPOSURE keeps the preview out of your results.
Related concepts
- Multi-context identity: using non-user keys for bucketing.
- Bandits: a bandit keeps re-deciding as it learns, so its assignments are not stored.
- Holdouts: holdout membership uses its own hash, not the sticky store.