When I was younger and I was involved in reverse engineering communities and systems programming, there was this concept called bit flags. They were a standard way of storing a pack of true or false values in ... actually a single value - a function parameter, local variable or entry in some configuration. I found nothing fascinating about that back then and just used it on a regular basis, as every other engineer was.
Long time after that and after shifting my focus to web development I just realized this concept exists and is widely used, but for some reason not in web dev. Several years passed but still, I have not seen even a single usage - neither in the frontend code nor in the backends.
Today I am going to walk through the whole idea and the pros and cons of this approach, the approach that allows storing up to 32 eventual boolean values in a single integer.
There may be a lot more issues with implementing any kind of flags system than it may seem to be. Including not only technical considerations or DX-affecting code smells but also things that are bad for business. For instance:
JSON-based flags systems in possibly making excessive bandwidth (even if using GraphQL) and computing power usage (which also increases infrastructure costs) or growing latencies when listing the flags over the network for applications with enormous network traffic where performance is absolutely critical.
JSON-based flags systems stored without end to end operation safety, creating pain points between application and database layers like discrepancies in areas such as: Strong types across many projects in monorepo and multirepo in different programming languages and tech. Formatting, stringifying, parsing, charset, possibly
High throughput in-memory processing systems with large volumes of data, causing RAM spikes with immutability patterns - huge data structures being copied over and over
Inefficient, hastily designed unstructured flags systems at the beginning of the project's development phase. Without scalability capabilities, they increase the costs of modernizing quick-to-become legacy and problematic infrastructures that create problems "out of nowhere" because "it worked yesterday," potentially delaying previously planned business strategies.
Defining flags as separate columns in the database modeling phases, ending up in a mess such as 20 columns named is_something_enabled . While this approach is quite performant and should be the go-to solution if no more than ~4 flags are needed, the growing number of booleans to store will create a mess
For educational purposes of this article while reading further sections - let's imagine building a rich text editor with user preferences being saved server-side (in the remote database). Assume the flags are for three things:
If the default browser's spell checker is being disabled (name of the flag: BUILTIN_SPELLCHECK_DISABLED ) If the third-party spell checkers is being disabled (name of the flag: THIRD_PARTY_SPELLCHECK_DISABLED ) If the text area should be resizable (if the bottom right arrow could be used to adjust the height of the input by the user) (name of the flag: RESIZABLE_TEXT_AREA )
Note The reason behind checking if the fields are actually disabled and not enabled is that by default browser enables its native spell checking over all textareas found in the currently opened web page(currently opened tab), same as third party extensions to assist with the spelling of the words. This behavior can be observed "in the wild" as dotted red underlines near misspelled words or grammar issues.
Imagine having a row of light switches. Each switch can be either ON or OFF, representing a state between true and false, a state between 1 and 0
Reference drawing explaining the light switches assumed concept and its representation in actual binary number and bits.
This "row of light switches" actually has its own name in the computer science - it is called binary number, and every particular switch is a bit
Let's define the flags as a plain object with numbers
const EditorFlags = { NONE : 0 , // 00000000 (dec: 0, hex: 0x0) BUILTIN_SPELLCHECK_DISABLED : 1 << 0 , // 00000001 (dec: 1, hex: 0x1) THIRD_PARTY_SPELLCHECK_DISABLED : 1 << 1 , // 00000010 (dec: 2, hex: 0x2) RESIZABLE_TEXT_AREA : 1 << 2 , // 00000100 (dec: 4, hex: 0x4) }
Tip The object's values can actually be defined as plain numbers - like 1 , 2 , 4 or others, but expressing them as bitwise signed left shift expressions ( << ) is way more readable as it instantly indicates which binary bit position is occupied. All the bitwise operators will be covered in the further sections of this article.
Take a while to look at it, especially the comments to every line. It can be observed that each of the defined flags converted to a binary number and placed under each other has exactly one bit toggled to 1 (every light switch) occupying a different "perceived column".
Reference drawing explaining the uniqueness of bits occupied at different positions when comparing all our defined flags.
These unique positions create a possibility to "merge" the flags into one value. In this case, the bitwise OR ( | ) operator will do the trick, by "summing up" the columns.
Reference drawing explaining the light switches assumed concept and its representation in actual binary number and bits.
In JavaScript/TypeScript the bitwise OR ( | ) operation would look like
const combinedFlags = EditorFlags . BUILTIN_SPELLCHECK_DISABLED | EditorFlags . THIRD_PARTY_SPELLCHECK_DISABLED | EditorFlags . RESIZABLE_TEXT_AREA console . log ( combinedFlags ) // 7
We get 000111 as the binary number result, 7 as decimal result and 0x7 as a hexadecimal one. This single number represents all of our flags combined. It can be saved anywhere, remote database, browser storages, in-memory states of the application or configuration files living directly in the filesystem.
Create the table CREATE TABLE user_editor_preferences ( id SERIAL PRIMARY KEY , user_id INTEGER NOT NULL UNIQUE , editor_flags INTEGER DEFAULT 0 , -- Stores bitflags for editor settings last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); Insert the data Manually just push the integer after bitwise operations on the application level INSERT INTO user_editor_preferences (user_id, editor_flags) VALUES ( 2 , 7 ) -- EditorFlags.BUILTIN_SPELLCHECK_DISABLED ─────────────┬──────────────┘ -- EditorFlags.THIRD_PARTY_SPELLCHECK_DISABLED ─┬───────┘ -- EditorFlags.RESIZABLE_TEXT_AREA ─────────────┘
Perform bitwise << and | on the query level: INSERT INTO user_editor_preferences (user_id, editor_flags) VALUES ( 1 , ( 1 << 0 ) | ( 1 << 1 ) | ( 1 << 2 )); -- └───┬──┘ └──┬───┘ └───┬──┘ -- │ │ │ -- │ │ └─ 1 << 2 = 4 (0x4) -- │ │ RESIZABLE_TEXT_AREA -- │ │ -- │ └─ 1 << 1 = 2 (0x2) -- │ THIRD_PARTY_SPELLCHECK_DISABLED -- │ -- └─ 1 << 0 = 1 (0x1) -- BUILTIN_SPELLCHECK_DISABLED
Note More ways of interacting with the bit flags, both on application and database level will be listed in further sections of the article
With the number containing all the flags stored somewhere, a way is needed to actually check if certain flags are enabled within this number. For this case, the AND operator ( & ) would be the way to go.
The operator scans each bit of the two binary numbers listed in the expression and returns a new number whose bits are set to 1 (light switches are set to ON) only if bits at the same "perceived columns" are toggled in the input numbers
Note Going bit by bit (column by column), theAND operator literally work in a way: If both bits are 1 then the bit in the result is 1
Otherwise, the bit in the result is 0
Reference drawing explaining the bitwise AND operator
In JavaScript/TypeScript the bitwise AND ( & ) operation would look like
const andOperationResult = combinedFlags & EditorFlags . THIRD_PARTY_SPELLCHECK_DISABLED console . log ( andOperationResult ) // 2
Looking at the code above and Fig. 4, the result does not tell much and it seems that the number is exactly equal to the THIRD_PARTY_SPELLCHECK_DISABLED which was just passed as the input binary number.
There's actually a reason for that. Getting back to the section explaining how the bitwise AND operator ( & ) works, it can be noticed that this is correct behavior and that comparing flags like this will always return the right-hand side of the operation if the flag being checked is present in the combined flags
Having that in mind, it's a perfect case for creating a helper factory function checking for equality to actually see if the particular flag is inside the stored number with some flags (all flags in this example) combined
export function bitflag ( input : number ) { return { has : ( flag : number ) => ( input & flag ) === flag ; } }
and using it
const hasThirdPartySpellcheckingDisabled = bitflag ( combinedFlags ) . has ( EditorFlags . THIRD_PARTY_SPELLCHECK_DISABLED ) console . log ( hasThirdPartySpellcheckingDisabled ) // true
Note To showcase the actual simplicity of the operations - after removing the assignments to the variables, abstractions, conversions of the decimal numbers to binary or hexadecimal, the whole log is literally just 7 & 2 === 2
The following query will return all rows where editor_flags has the THIRD_PARTY_SPELLCHECK_DISABLED
SELECT * FROM user_editor_preferences WHERE editor_flags & 2 = 2 -- BitFlags.THIRD_PARTY_SPELLCHECK_DISABLED ───────────────┴───┘
Warning The bitwise operators on read queries sent to the database have serious performance considerations that are highlighted in section"Arguments against bit flags approach"
In order to implement remove functionality for the bitflags, clever use of the NOT operator ( ~ ) and the AND operator ( & ) afterwards is needed.
Reference drawing explaining the bitwise AND + NOT operators
Analyzing the drawing, first the flag to be removed is obtained - in this case EditorFlags.THIRD_PARTY_SPELLCHECK_DISABLED , then all its bits (all the perceived columns) are flipped to the contrasting value (if it's 0, it will flip to 1, if it's 1 it will flip to 0)
After that, already knowing how the AND operator ( & ) works, the combinedFlags is used as the left-hand side and the mask as the right-hand side to perform the operation.
The returned value from the operation will be exact binary number having all our flags combined without EditorFlags.THIRD_PARTY_SPELLCHECK_DISABLED
Having that in mind, we can modify helper factory function from previous section to:
export function bitflag ( input : number ) { return { has : ( flag : number ) => ( input & flag ) === flag ; remove : ( flag : number ) => input & ~ flag ; } }
and use it as follows:
const flagsWithoutThirdPartySpellcheckDisabled = bitflag ( combinedFlags ) . remove ( EditorFlags . THIRD_PARTY_SPELLCHECK_DISABLED ) console . log ( flagsWithoutThirdPartySpellcheckDisabled ) // └───── equivalent of: // 000101 // BUILTIN_SPELLCHECK_DISABLED | RESIZABLE_TEXT_AREA // 1 | 4
Insert the data
Update the value directly with bitwise operations done on the application level INSERT INTO user_editor_preferences (user_id, editor_flags) VALUES ( 2 , 5 ) -- EditorFlags.BUILTIN_SPELLCHECK_DISABLED ─────┬──────────────────────┤ -- EditorFlags.RESIZABLE_TEXT_AREA ─────────────┘ │ -- calculated in JS/TS -- on application level Perform bitwise << , ~ and & on the query level: UPDATE user_editor_preferences SET editor_flags = editor_flags & ~( 1 << 1 ) WHERE user_id = 1 -- │ └───┬──┘ -- │ │ -- │ └─ THIRD_PARTY_SPELLCHECK_DISABLED (2) -- │ -- │ -- │ -- Current: 7 -- BUILTIN_SPELLCHECK_DISABLED (1) -- THIRD_PARTY_SPELLCHECK_DISABLED (2) -- RESIZABLE_TEXT_AREA (4) -- -- Result: Updated column with editor_flags = 5 -- BUILTIN_SPELLCHECK_DISABLED (1) -- RESIZABLE_TEXT_AREA (4)
While previously described operators cover most of the functionality needed, there's a common "bitwise-native" way to implement a QoL flags toggling mechanism. For that, the XOR operator ( ^ ) will be used.
To perform such an operation, the number where flags are combined is needed as the left-hand side and the flag to toggle as the right-hand side - it will be treated as some kind of "mask", similar to the mask created by the NOT operator ( ~ ).
After executing the logic, the bit toggled in the mask will be flipped in the number where flags are combined. And this can be repeated indefinitely to perform toggling (ON/OFF) on the particular flag
Reference drawing explaining the bitwise XOR operator
Having that in mind, we can modify helper factory function from previous sections once again:
export function bitflag ( input : number ) { return { has : ( flag : number ) => ( input & flag ) === flag ; remove : ( flag : number ) => input & ~ flag ; toggle : ( flag : number ) => input ^ flag ; } }
and use it as follows:
const flagsWithoutThirdPartySpellcheckDisabled = bitflag ( combinedFlags ) . toggle ( EditorFlags . THIRD_PARTY_SPELLCHECK_DISABLED ) console . log ( flagsWithoutThirdPartySpellcheckDisabled ) // └───── equivalent of: // 000101 // BUILTIN_SPELLCHECK_DISABLED | RESIZABLE_TEXT_AREA // 1 | 4 console . log ( bitflag ( flagsWithoutThirdPartySpellcheckDisabled ) . toggle ( EditorFlags . THIRD_PARTY_SPELLCHECK_DISABLED )) // equivalent of: ───────────────────────────────┘ // 000111 // BUILTIN_SPELLCHECK_DISABLED | THIRD_PARTY_SPELLCHECK_DISABLED | RESIZABLE_TEXT_AREA // 1 | 2 | 4
The text editor implementation and bit flags although being a perfectly simple example to showcase the functionality of bit flags is not a case that requires the best performance and serious optimization considerations.
Bit flags come especially handy in network-heavy operations and high-frequency in-memory data processing, basically anything that will potentially result in excessive computing or money cost rising in pair with the growing volume of data and frequency of the accesses of such data. For instance:
Permission systems and roles (authorization). Comprehensive and heavy logging/telemetry/analytics mechanisms. Game development - in particular implementing multiplayer and creating high-throughput game servers or checking loads of states of game objects/entities per second.
Note As in the first paragraph of this section, all of the elements in the list have one concern in common - not only the stored data volume matters but also the bandwidth as these things are accessed a lot, either by database and internal communications or exposed via network protocols (e.g. HTTP) to the client
There's loads of theoretical knowledge in the article; quickly hopping into real world codebases introducing these concepts can be cumbersome, especially for developers encountering this for the first time.
The factory function that was mentioned in particular sections regarding specific operations is a perfect "bridge" between the article and the actual code. In its final form, it would look like this
export function bitflag ( value = 0 ) { const combine = (... flags ) => flags . reduce ( ( acc , flag ) => acc | flag , 0 ) return { has : (... flags ) => flags . length === 0 ? false : ( value & combine ( ... flags )) === combine ( ... flags ) , hasAny : (... flags ) => flags . length === 0 ? false : ( value & combine ( ... flags )) !== 0 , hasExact : (... flags ) => flags . length === 0 ? value === 0 : value === combine ( ... flags ) , add : (... flags ) => ( flags . length === 0 ? value : value | combine ( ... flags )) , remove : (... flags ) => flags . length === 0 ? value : value & ~ combine ( ... flags ) , toggle : (... flags ) => flags . length === 0 ? value : value ^ combine ( ... flags ) , clear : () => 0 , value , valueOf : () => value , toString : () => value . toString () } }
This minimal mixed-pardagim (Object Oriented Programming + Functional Programming) implementation can be copied and pasted to any codebase, for instance you can create the files:
/lib/bitflag.ts in Next.js projects and imported both in RSC or client context
in projects and imported both in RSC or client context /shared/utils/bitflag.ts for Nuxt.js projects and imported both in Nitro backend and Vue frontend
Note This implementation is double isomorphic - not only it will work the same with JavaScript and TypeScript(here, all the types will be auto inferred in strict mode) but also it will work both in server/client environments when building web applications.
then define the flags somewhere in /constants/ directory of your choice
const MyFlags = { NONE = 0 , MY_FIRST_FLAG = 1 << 0 , // ... MY_NINTH_FLAG = 1 << 8 }
and use it as follows
import { bitflag } from "./mycodebase/utils/bitflags" import { MyFlags } from "./mycodebase/utils/constants" bitflag ( MyFlags . MY_FIRST_FLAG ) . add ( MY_NINTH_FLAG )
While writing this article, I came up with an idea of extracting the utility factory function not only to the ready-to-copy section in this article but also as a separate library, making it convenient for more advanced usages, e.g. writing highly reliable applications where end-to-end type safety is needed.
Type safety via Tagged 1 types.
types. Lightweight and fast, almost native bitwise performance with minimal abstraction layer.
No runtime dependencies.
Robust and ready-to-use on production. 100% test coverage
.describe() iterator for better debugging and visualization of the flags.
pnpm bun yarn npm pnpm add bitf bun add bitf yarn add bitf npm install bitf
import { type Bitflag , bitflag , defineBitflags } from "bitf" ; // This should probably live in a file shared between frontend/backend contexts const Toppings = defineBitflags ( { CHEESE : 1 << 0 , PEPPERONI : 1 << 1 , MUSHROOMS : 1 << 2 , OREGANO : 1 << 3 , PINEAPPLE : 1 << 4 , BACON : 1 << 5 , HAM : 1 << 6 , } ) ; // Can be mapped on frontend using InferBitflagsDefinitions and .describe() function type PizzaOrderPreferences = Readonly<{ desiredSize : "small" | "medium" | "large" ; toppingsToAdd : Bitflag; toppingsToRemove : Bitflag; }>; export async function configurePizzaOrder ({ desiredSize , toppingsToAdd , toppingsToRemove , }: PizzaOrderPreferences) { if ( bitflag ( toppingsToRemove ) . has ( Toppings . CHEESE )) throw new Error ( "Cheese is always included in our pizzas!" ) ; const defaultPizza = bitflag () . add ( Toppings . CHEESE , Toppings . PEPPERONI ) ; const pizzaAfterRemoval = processToppingsRemoval ( defaultPizza , toppingsToRemove ) ; validateMeatAddition ( pizzaAfterRemoval , toppingsToAdd ) ; // ... some additional logic like checking the toppings availability in the restaurant inventory // ... some additional logging using the .describe() function for comprehensive info const finalPizza = bitflag ( pizzaAfterRemoval ) . add ( toppingsToAdd ) ; return { size : desiredSize , pizza : finalPizza , metadata : { hawaiianPizzaDiscount : bitflag ( finalPizza ) . hasExact ( Toppings . CHEESE , Toppings . HAM , Toppings . PINEAPPLE ) , }, }; } function processToppingsRemoval ( currentPizza : Bitflag, toppingsToRemove : Bitflag ) { if ( toppingsToRemove ) return bitflag ( currentPizza ) . remove ( toppingsToRemove ) ; return currentPizza ; } function validateMeatAddition ( currentPizza : Bitflag, toppingsToAdd : Bitflag) { const currentHasMeat = bitflag ( currentPizza ) . hasAny ( Toppings . PEPPERONI , Toppings . BACON , Toppings . HAM ) ; const requestingMeat = bitflag ( toppingsToAdd ) . hasAny ( Toppings . PEPPERONI , Toppings . BACON , Toppings . HAM ) ; if ( currentHasMeat && requestingMeat ) throw new Error ( "Only one type of meat is allowed per pizza!" ) ; } Show full code
To learn more about the library, check out its GitHub repository!
The performance takes on at the beginning of the article are serious considerations, that is also a good reason behind benchmarking the common aproaches to managing all sort of flags in the application's internal systems.
The benchmarks are done in scope of bitf library and custom-made JSON-based flags implementation that aims to be as close as possible to the common patterns of creating such abstractions in web development - with immutability, spread operator and Object methods.
Note Please take note that the benchmarks are taking the bitf library into consideration regarding comparison, not native bitwise operators inlined in the code! There's slight difference in performance between these two. The native operators seems to be around ~2-4 million ops/s faster than its library equivalents due to complete lack of any application-level abstractions. This may seem like a lot but in a scale of 30m ops/s the difference seems rather minimal than impactful.
Tested on Node.js v24.3.0, with process.versions printing:
{ " node " : "24.3.0" , " acorn " : "8.15.0" , " ada " : "3.2.4" , " amaro " : "1.1.0" , " ares " : "1.34.5" , " brotli " : "1.1.0" , " cjs_module_lexer " : "2.1.0" , " cldr " : "47.0" , " icu " : "77.1" , " llhttp " : "9.3.0" , " modules " : "137" , " napi " : "10" , " nbytes " : "0.1.1" , " ncrypto " : "0.0.1" , " nghttp2 " : "1.66.0" , " openssl " : "3.0.16" , " simdjson " : "3.13.0" , " simdutf " : "6.4.0" , " sqlite " : "3.50.1" , " tz " : "2025b" , " undici " : "7.10.0" , " unicode " : "16.0" , " uv " : "1.51.0" , " uvwasi " : "0.0.21" , " v8 " : "13.6.233.10-node.18" , " zlib " : "1.3.1-470d3a2" , " zstd " : "1.5.7" }
Tested on Bun 1.2.18 with process.versions printing:
{ " node " : "24.3.0" , " bun " : "1.2.18" , " boringssl " : "29a2cd359458c9384694b75456026e4b57e3e567" , " openssl " : "1.1.0" , " libarchive " : "898dc8319355b7e985f68a9819f182aaed61b53a" , " mimalloc " : "4c283af60cdae205df5a872530c77e2a6a307d43" , " picohttpparser " : "066d2b1e9ab820703db0837a7255d92d30f0c9f5" , " uwebsockets " : "0d4089ea7c48d339e87cc48f1871aeee745d8112" , " webkit " : "29bbdff0f94f362891f8e007ae2a73f9bc3e66d3" , " zig " : "0.14.1" , " zlib " : "886098f3f339617b4243b286f5ed364b9989e245" , " tinycc " : "ab631362d839333660a265d3084d8ff060b96753" , " lolhtml " : "8d4c273ded322193d017042d1f48df2766b0f88b" , " ares " : "d1722e6e8acaf10eb73fa995798a9cd421d9f85e" , " libdeflate " : "dc76454a39e7e83b68c3704b6e3784654f8d5ac5" , " usockets " : "0d4089ea7c48d339e87cc48f1871aeee745d8112" , " lshpack " : "3d0f1fc1d6e66a642e7a98c55deb38aa986eb4b0" , " zstd " : "794ea1b0afca0f020f4e57b6732332231fb23c70" , " v8 " : "13.6.233.10-node.18" , " uv " : "1.48.0" , " napi " : "10" , " icu " : "74.2" , " unicode " : "15.1" , " modules " : "137" }
Important Name overlap between Node.js and Bun's process.versions properties is because of the compatibility layer. The actual JavaScriptCore version is equal to the process.versions.webkit property.
The following benchmarks are theoretical as seeding a database with such data would be quite complex; however, simple math for the scenario is still a reliable way to calculate these.
Numeric assumptions for a mid-scale SaaS project management platform with:
300,000 users (typical for successful B2B SaaS)
31 permission flags per user (maximum for JavaScript's signed 32-bit integer)
675 million permission checks per month (average 75 checks per user per day)
With flags columns configurations:
JSONB, with the default flags assigned per user. Taking space equal to 935 bytes raw -> 809 bytes minified => ~814 bytes (+5 bytes JSONB type) { " can_view_docs " : true , " can_edit_docs " : false , " can_delete_docs " : false , " can_manage_users " : true , " can_view_analytics " : false , " can_export_data " : true , " can_manage_billing " : false , " can_create_workspaces " : true , " can_invite_members " : true , " can_remove_members " : false , " can_manage_integrations " : false , " can_view_audit_logs " : true , " can_manage_security " : false , " can_create_projects " : true , " can_archive_projects " : false , " can_view_reports " : true , " can_export_reports " : false , " can_manage_api_keys " : false , " can_view_team_activity " : true , " can_manage_webhooks " : false , " can_configure_sso " : false , " can_manage_roles " : false , " can_view_billing " : true , " can_change_plan " : false , " can_access_beta " : false , " can_manage_domains " : false , " can_view_usage " : true , " can_manage_backups " : false , " can_access_support " : true , " can_manage_branding " : false , " can_view_insights " : true } Show full code
31 boolean columns Taking space equal to 31 bytes raw (one column is 1 byte)
Integer (bitflags through bitf library, with default flags per user, composed on the application level and sent to the database layer) Taking space equal to 4 bytes raw import { defineBitflags , bitflag } from 'bitf' ; // Define all 31 permission flags const Permissions = defineBitflags ( { NONE : 0 , CAN_VIEW_DOCS : 1 << 0 , // 0x1 CAN_EDIT_DOCS : 1 << 1 , // 0x2 CAN_DELETE_DOCS : 1 << 2 , // 0x4 CAN_MANAGE_USERS : 1 << 3 , // 0x8 CAN_VIEW_ANALYTICS : 1 << 4 , // 0x10 CAN_EXPORT_DATA : 1 << 5 , // 0x20 CAN_MANAGE_BILLING : 1 << 6 , // 0x40 CAN_CREATE_WORKSPACES : 1 << 7 , // 0x80 CAN_INVITE_MEMBERS : 1 << 8 , // 0x100 CAN_REMOVE_MEMBERS : 1 << 9 , // 0x200 CAN_MANAGE_INTEGRATIONS : 1 << 10 , // 0x400 CAN_VIEW_AUDIT_LOGS : 1 << 11 , // 0x800 CAN_MANAGE_SECURITY : 1 << 12 , // 0x1000 CAN_CREATE_PROJECTS : 1 << 13 , // 0x2000 CAN_ARCHIVE_PROJECTS : 1 << 14 , // 0x4000 CAN_VIEW_REPORTS : 1 << 15 , // 0x8000 CAN_EXPORT_REPORTS : 1 << 16 , // 0x10000 CAN_MANAGE_API_KEYS : 1 << 17 , // 0x20000 CAN_VIEW_TEAM_ACTIVITY : 1 << 18 , // 0x40000 CAN_MANAGE_WEBHOOKS : 1 << 19 , // 0x80000 CAN_CONFIGURE_SSO : 1 << 20 , // 0x100000 CAN_MANAGE_ROLES : 1 << 21 , // 0x200000 CAN_VIEW_BILLING : 1 << 22 , // 0x400000 CAN_CHANGE_PLAN : 1 << 23 , // 0x800000 CAN_ACCESS_BETA : 1 << 24 , // 0x1000000 CAN_MANAGE_DOMAINS : 1 << 25 , // 0x2000000 CAN_VIEW_USAGE : 1 << 26 , // 0x4000000 CAN_MANAGE_BACKUPS : 1 << 27 , // 0x8000000 CAN_ACCESS_SUPPORT : 1 << 28 , // 0x10000000 CAN_MANAGE_BRANDING : 1 << 29 , // 0x20000000 CAN_VIEW_INSIGHTS : 1 << 30 , // 0x40000000 } ) ; // Compose user permissions matching the JSON example const userPermissions = bitflag ( Permissions . NONE ) . add ( Permissions . CAN_VIEW_DOCS , Permissions . CAN_MANAGE_USERS , Permissions . CAN_EXPORT_DATA , Permissions . CAN_CREATE_WORKSPACES , Permissions . CAN_INVITE_MEMBERS , Permissions . CAN_VIEW_AUDIT_LOGS , Permissions . CAN_CREATE_PROJECTS , Permissions . CAN_VIEW_REPORTS , Permissions . CAN_VIEW_TEAM_ACTIVITY , Permissions . CAN_VIEW_BILLING , Permissions . CAN_VIEW_USAGE , Permissions . CAN_ACCESS_SUPPORT , Permissions . CAN_VIEW_INSIGHTS ) ; userPermissions . value // Value to store in Integer column in database = 1616904377 (0x60648AB9) (4 bytes) Show full code
Note For the calculations, the row overhead (24 bytes on most machines) + item pointer (4 bytes) is being taken into consideration. Multiplying the value of 28 bytes per number of rows gives total row overhead for assumed table with only one column being the mentioned data type approach. The null bitmap (present if a field is nullable) is excluded as I assumed that they are non-nullable.
Example responses from the backend to the client handling the communication with database internally:
Minimal JSON API Response (with size of 1039 bytes uncompressed and 351 bytes after gzip ) { " userId " : 123456 , " permissions " : { " can_view_docs " : true , " can_edit_docs " : false , " can_delete_docs " : false , " can_manage_users " : true , " can_view_analytics " : false , " can_export_data " : true , " can_manage_billing " : false , " can_create_workspaces " : true , " can_invite_members " : true , " can_remove_members " : false , " can_manage_integrations " : false , " can_view_audit_logs " : true , " can_manage_security " : false , " can_create_projects " : true , " can_archive_projects " : false , " can_view_reports " : true , " can_export_reports " : false , " can_manage_api_keys " : false , " can_view_team_activity " : true , " can_manage_webhooks " : false , " can_configure_sso " : false , " can_manage_roles " : false , " can_view_billing " : true , " can_change_plan " : false , " can_access_beta " : false , " can_manage_domains " : false , " can_view_usage " : true , " can_manage_backups " : false , " can_access_support " : true , " can_manage_branding " : false , " can_view_insights " : true } } Show full code
Miminal JSON API Response withi one JSON field holding bit flags (with size of 42 bytes , non-applicable for gzip) { " userId " : 123456 , " permissions " : 1616904377 }
Note The bandwidth for 675 million checks for 31 boolean columns will be extremely similar to the regular JSON permissions, as the boolean columns need to compose an object of permissions for reliable communication between environments such as backend and frontend.
Internal database and application communication systems may not be worth migrating after calculating the risk coming along with the migration and the engineering effort to do it properly. Don't make an issue from things that are not an issue.
While introducing the bitflags concept either in cases where eventual optimization gains are significant or in cases where a new codebase is created from scratch - other engineers and developers may say this approach is completely unreadable, e.g. in the code review phase. These concerns are perfectly valid, but please keep in mind that the amount of usually perceived web development "code smells" can be reduced to a minimum in the application code by using the ready-to-copy factory utility function encapsulating the bitflags concept from Implementing bit flags mechanisms in your codebase section or installing the bitf library Warning Unfortunately, database query construction can still introduce a steep learning curve for how to structure bitflags filtering conditions and how to avoid performance bottlenecks.
In most database engines (not only PostgreSQL) bitwise operations are non-SARGable23 (non search-argumentable) meaning they cannot use regular indexes and the conditions to filter huge datasets by some bitwise operation would result in full database sequential scan that may be terrible regarding the performance (>300ms query time). There is no need to be alarmed though; in most cases, the results for a bitwise scan can be narrowed from enormous numbers to amounts that just match the criteria. For example, having 3 million users checking permissions of a particular organization (let's assume it has 300 users), the results can be narrowed before applying the permission filter WHERE organization_id = 123 -- SARGable AND created_at > '2024-01-01' -- SARGable AND status = 'active' -- SARGable AND (permissions & 16 ) = 16 ; -- non-SARGable This will minimize the (possibly parallel) sequential filtering scan of bitwise operations, therefore won't impact performance badly, and the query will take <10ms
The manual experimenting and exploring the performance impact with various setups is highly encouraged.
Setting up the table, seeding the data and creating indexes -- Create test table with 3 million rows CREATE TABLE users ( id SERIAL PRIMARY KEY , organization_id INTEGER , created_at TIMESTAMP , status VARCHAR ( 20 ), permissions INTEGER , data JSONB ); -- Insert test data INSERT INTO users (organization_id, created_at, status , permissions, data ) SELECT (random () * 1000 ):: int , NOW () - (random () * 365 ):: int * INTERVAL '1 day' , CASE WHEN random () > 0 . 2 THEN 'active' ELSE 'inactive' END , (random () * 255 ):: int , jsonb_build_object( 'name' , 'User' || generate_series) FROM generate_series ( 1 , 3000000 ); -- Create indexes CREATE INDEX idx_organization ON users(organization_id); CREATE INDEX idx_created_at ON users(created_at); CREATE INDEX idx_status ON users( status ); CREATE INDEX idx_composite ON users(organization_id, status , created_at); Show full code Query performance measurements -- Analyze query without optimization EXPLAIN ANALYZE SELECT * FROM users WHERE (permissions & 16 ) = 16 ; -- Result: Parallel Sequential Scan, ~300ms for 3M rows -- Analyze query with SARGable filtering EXPLAIN ANALYZE SELECT * FROM users WHERE organization_id = 123 AND status = 'active' AND created_at > '2024-01-01' AND (permissions & 16 ) = 16 ; -- Result: Index Scan + Filter, ~5ms for same result set Show full code
Tip Experimentation with partial indexes for ~1ms queries is also possible
While up to 32 booleans may be stored when working with integers in lower-level languages or systems with direct access to the u32 or uint32_t types, the actual recommended number of flags (booleans) to store in JavaScript/TypeScript is 31.
However, there is a good reason for that. Under the hood, JavaScript (and TypeScript) Number runtime type is always a 64-bit IEEE 754 double-precision floating point both as a storage unit and during operations on Number s. At first glance, it may seem like up to 64 booleans could be stored in one value, but in reality implicit integer operations are 32-bit signed integers. This means that the first bit is a sign bit indicating whether the number is positive or negative and should not be modified manually due to data integrity and compatibility problems. Due to the truncated size during those operations, only 31 bits should actually be considered for usage from the initial 64 bits.
But there's a more worth knowing to that:
All Number s being floating point does not exclude integer bitwise operations. That is why I called these "implicit" in the previous paragraph. When you use the bitwise operators, JavaScript temporarily converts the number from a double-precision floating point to a truncated 32 bit signed integer , performs the operation and then converts it back to a floating point. const fakeInteger = 2 ; // actually stored as 2.0 const floatingPoint = 8.0 ; // stored as 8.0 const bitwiseOperationResult = 1 | fakeInteger | 4 | floatingPoint | 16 // └─ converted to 1 | 2 | 4 | 8 | 16 == 31 and eventually stored as 31.0
There are no other ways to work on a different type of numbers in JavaScript apart from bitwise operations or Typed Arrays.
Note There are BigInt s as potential alternative to the mentioned options for storing the data, but their implementation is a bit different than "standard" computer-science-wide data types, as they are designed for maximum precision when working with huge numbers e.g. in mathematics or cryptography by introducing a layer of abstraction. Personally I would not recommend approaching the bit flags concept with intention to use BigInt , as it may solve the main problem of amount of bit flags stored but it will also create loads of other problems on its own.
For simplicity purposes I've shortened the numbers on all drawings (figures) since all the zeros would not fit inside the drawings without sacrificing legibility. In real life, 32 bit integers and all operations should be represented in the following way:
Reference drawing explaining the actual count of zeros in binary numbers
I've explored all the numbers using BitwiseCMD4 calculator - exploring it yourself is highly encouraged!
When writing this article and creating the bitf library, I also wanted to create separate transpiler plugins for inlining the utility function's usage as babel-plugin-inline-bitf and swc-plugin-inline-bitf , but in the end it would be overkill and a potentially huge area where errors could arise, so eventually I declined this idea.
Creating integrations for query builders such as Kysely or integrating it directly in Drizzle or Prisma ORMs can be a clever way to make the bitflags querying between application layer and database layer even easier and more straightforward.