TL;DR
- A bitmask packs multiple yes/no flags into a single integer. Each flag occupies one bit position; the number
5(binary0101) means positions 0 and 2 are on, positions 1 and 3 are off. - Four operators cover all flag manipulation: OR sets flags, AND checks them, AND NOT clears them, XOR toggles them. Each operation is a single CPU instruction.
- A bitwise AND check is 1.5x faster than
.includes()on an array and uses 46% less memory than storing the same flags as boolean object properties. - The pattern is not new. Unix
chmod 755, TCP header flags, and game engine entity state all use it. For the full permission system implementation, see Encoding RBAC as a JWT Integer Without Redis.
Most developers have seen code like user.permissions & CAN_DELETE and moved on without understanding what the & does or why it is written that way. The expression is not clever syntax. It is a lookup that runs in a single CPU instruction because the entire permission set lives inside one number.
This article explains how binary numbers encode multiple states, how the four bitwise operators manipulate them, and where this pattern shows up in production so you can read and write it with confidence.
How binary numbers work
Humans count with ten digits (0 through 9). When a position runs out of digits, it carries over to the next column:
8, 9, 10, 11 ... 98, 99, 100Computers use two digits: 0 and 1, because transistors have two states: off and on. They carry over at 2 instead of 10:
0, 1, 10, 11, 100, 101, 110, 111, 1000 ...This is base 2, or binary. The positional rule is the same as decimal: each column is worth the base raised to that position's power. In decimal, columns are worth 1, 10, 100. In binary, columns are worth 1, 2, 4, 8:
position: 3 2 1 0
value: 8 4 2 1The number 5 in binary is 0101. Position 2 is on (worth 4), position 0 is on (worth 1). The others are off:
position: 3 2 1 0
bit: 0 1 0 1
value: 0 + 4 + 0 + 1 = 5Using bits to represent feature flags
Once you see that each bit position is an independent on/off slot, it becomes possible to use a single integer as a set of named boolean states. Assign a meaning to each position:
position: 3 2 1 0
meaning: beta notifications analytics dark mode
bit: 0 1 0 1A user with dark mode and notifications enabled is stored as 0101, which equals 5. All four settings live in one integer.
This is a bitmask: a single integer used to encode multiple boolean states, one per bit position.
Packing multiple settings into one integer
The conventional approach stores each flag as a named property:
const userSettings = {
darkMode: true,
analytics: false,
notifications: true,
betaAccess: false,
};This is readable, but each object carries the property name strings, the values, and object overhead. Across millions of records, that adds up. Storing 100,000 of these objects uses 12.05 MB versus 6.47 MB for the equivalent bitmask integers.
The bitmask approach assigns each flag a power of two using the left-shift operator, which is shorthand for "1 shifted left by n positions." The pattern makes each constant's bit position explicit and prevents the typos that come from writing the powers of two by hand:
const FLAGS = {
DARK_MODE: 1 << 0, // 0001 = 1
ANALYTICS: 1 << 1, // 0010 = 2
NOTIFICATIONS: 1 << 2, // 0100 = 4
BETA_ACCESS: 1 << 3, // 1000 = 8
} as const;A user with dark mode and notifications enabled combines the two flags with OR:
const userSettings = FLAGS.DARK_MODE | FLAGS.NOTIFICATIONS;
// 0001 | 0100 = 0101 = 5One number. Four settings. The object with its property name strings no longer exists.
The four operations
Every flag manipulation maps to one of four bitwise operators. Each is a single CPU instruction.
- Setting flags with OR
OR turns bits on. If either operand has a 1 in a position, the result has a 1 there. Use it to enable one or more flags at once:
let settings = 0; // 0000 — nothing set
settings = FLAGS.DARK_MODE | FLAGS.ANALYTICS; // 0001 | 0010 = 0011 = 3- Checking flags with AND
AND tests whether a specific bit is on. The result is non-zero only if both operands share a 1 in the same position. Non-zero means the flag is set; zero means it is not:
const settings = 5; // 0101
if (settings & FLAGS.DARK_MODE) {
// 0101 & 0001 = 0001 — non-zero, dark mode is on
}
if (settings & FLAGS.ANALYTICS) {
// 0101 & 0010 = 0000 — zero, analytics is off
}- Clearing flags with AND NOT
To turn a specific bit off without touching any other, first use ~ to flip all bits in the flag, then AND the result against your value. The flipped mask is all 1s except the one position you want to clear:
let settings = 5; // 0101: dark mode on, notifications on
settings = settings & ~FLAGS.DARK_MODE;
// ~FLAGS.DARK_MODE = ~0001 = 1110
// 0101 & 1110 = 0100 = 4: dark mode off, notifications still on- Toggling flags with XOR
XOR flips a bit regardless of its current state: 1 becomes 0, 0 becomes 1. It is the natural operator for toggle switches:
let settings = 5; // 0101: analytics off
settings ^= FLAGS.ANALYTICS; // 0101 ^ 0010 = 0111 = 7: analytics now on
settings ^= FLAGS.ANALYTICS; // 0111 ^ 0010 = 0101 = 5: analytics off againWhy this matters at scale
The performance advantage comes from how the check executes. A bitwise AND is a single instruction that the CPU evaluates in one cycle. There are no function calls, no heap allocation, no string comparisons. Checking settings & FLAGS.DARK_MODE against checking settings.includes("dark_mode") on an array of string names:
| Approach | Ops/second | vs baseline |
|---|---|---|
Array .includes() | 2,093,325 | baseline |
Bitmask & check | 3,012,325 | 1.5x faster |
Memory tells a similar story. Across 100,000 user objects with four flags each:
| Approach | Total | Per object |
|---|---|---|
| Multiple boolean properties | 12.05 MB | 126 bytes |
| String array of flag names | 11.58 MB | 121 bytes |
| Single bitmask integer | 6.47 MB | 68 bytes |
At 10 million users, the bitmask approach saves roughly 558 MB compared to boolean properties. A single integer assignment is also atomic at the hardware level; updating three separate boolean properties is not, so a read between writes can observe a partially updated state.
Where this pattern already exists
Once you know the pattern, you see it everywhere systems were built for performance.
Unix file permissions encode read, write, and execute for owner, group, and others as nine bits spread across three groups. Running chmod 755 sets owner to 7 (binary 111: all three on), group to 5 (binary 101: read and execute on, write off), and others to 5. Nine independent permissions, stored and checked as a single integer.
TCP packet headers carry a flags byte that encodes SYN, ACK, FIN, RST, PSH, and URG. When a server receives a packet, it checks flags & SYN to detect a connection request. One byte, six independent states, billions of times per second.
Game engines track entity state (visible, collidable, active, paused, destroyed) as an integer per entity. The game loop checks flags for thousands of objects per frame. Allocating a boolean object for each would slow the loop down noticeably.
For the production implementation of this pattern in an auth permission system, see Encoding RBAC as a JWT Integer Without Redis.
When this breaks down
32-flag ceiling with bitwise operators. JavaScript's |, &, ~, and ^ coerce operands to signed 32-bit integers, giving you 31 usable positions before the sign bit. You can extend to 53 by using regular arithmetic instead of bitwise operators, or go beyond 53 with BigInt:
const FLAGS = {
FLAG_1: 1n << 0n,
FLAG_33: 1n << 32n,
FLAG_65: 1n << 64n,
} as const;
const hasFlag = (value: bigint, flag: bigint): boolean => (value & flag) !== 0n;Raw values in logs. console.log(settings) prints 5, not ["DARK_MODE", "NOTIFICATIONS"]. A small helper that maps set bits back to names resolves this during development. Keep it out of the hot path; it is for debugging only:
export function debugFlags(value: number): string[] {
return Object.entries(FLAGS)
.filter(([, flag]) => (value & (flag as number)) !== 0)
.map(([name]) => name);
}
// debugFlags(5) => ["DARK_MODE", "NOTIFICATIONS"]No dynamic flags. Bit positions must be assigned at compile time. If your application lets users define their own flag types at runtime, you need a different approach: a junction table or a serialized set.
Bit position stability. Once a permission occupies a position, that position is taken permanently. Reusing it for a different flag corrupts any stored integers and any tokens that carry the old value. Treat the flag schema like a database migration: only ever add positions, never reassign them.
This pattern works best when the flag set is fixed at deploy time, checks run in a hot path, and the number of flags stays under the integer ceiling. For small, infrequently-checked flag sets, the readability of named properties may outweigh the performance difference.
Key takeaways
A bitmask stores multiple boolean flags in one integer: OR sets bits, AND checks them, AND NOT clears them, and XOR toggles them, each in a single CPU instruction with no heap allocation. At scale, a bitmask check runs roughly 1.5x faster than an array include and uses half the memory of individual boolean properties across millions of objects. JavaScript's bitwise operators coerce to signed 32-bit integers, giving you 31 usable positions before the sign bit; switch to BigInt arithmetic when your flag set exceeds that limit. Bit positions are permanent once assigned: treat the flag schema like a database migration and only ever add new positions, never reassign existing ones.

