CS Foundations

Binary, Endianness and Bit Tricks

Reading binary and hex, two's complement, JavaScript's 32-bit bitwise operators, masks, endianness and DataView.

Lesson 0417 min read#binary#endianness#bit-manipulation

On this page

  1. Words used in this lesson
  2. Reading binary and hex
  3. Two's complement: how negative numbers are stored
  4. JavaScript bitwise operators use 32-bit signed integers
  5. Masks and flags
  6. Common tricks
  7. Bit sets for permissions
  8. Endianness and byte order
  9. Common mistakes
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: a computer stores everything as bits, and a few facts explain most bit questions in interviews: two's complement makes negative numbers work, JavaScript bitwise operators use 32-bit signed integers, >>> is the one that does not keep the sign, and endianness is only the order of bytes in memory or on the wire.

A bit is one binary digit, 0 or 1. A byte is 8 bits. Most bit questions in interviews are not tricks. They test whether you know how numbers are stored and what your language does to them. This lesson is about reading binary and hex, how negative numbers are stored, what |, &, ^, <<, >> and >>> really do in JavaScript, a short list of useful tricks, byte order, and how to use a bit set for permissions.

The DSA series already has a problem-focused lesson on this topic: DSA Lesson 58, Tries and Bit Manipulation. That lesson is about solving problems (single number, counting bits, subsets with bitmasks). This lesson does not repeat the problem list. It explains the machinery under those problems. For why a computer works in bits at all, see Lesson 0. For floating point numbers, which JavaScript uses for every plain number, see Lesson 2.

What I ran / checked. Every code block below was run on Node 22.22.0 on Linux x64, and the output shown is what it printed. I read the MDN pages for the right shift operator (32-bit conversion, shift count taken modulo 32) and for DataView.prototype.setUint32 (the littleEndian argument). I did not read the ECMAScript text for each operator; I tested the behaviour instead. The "host is little-endian" result is for my machine only. Speed claims for bit tricks are not measured here.

Words used in this lesson

  • Binary is base 2: digits 0 and 1. Hexadecimal (hex) is base 16: digits 0-9 and a-f. One hex digit is exactly 4 bits, so one byte is two hex digits.
  • Two's complement is the way most computers store signed integers. The top bit has a negative weight.
  • Signed means the number can be negative. Unsigned means 0 or more only.
  • A mask is a number whose set bits pick out some bits of another number.
  • A flag is one bit that means yes or no, such as "can write".
  • Endianness is the order of the bytes of a multi-byte number. Big-endian puts the most significant byte first. Little-endian puts the least significant byte first.
  • Network byte order is big-endian. It is the order used in many network protocol headers.
  • Popcount (population count) is the number of 1 bits in a number.

Reading binary and hex

Each bit has a weight that doubles as you go left: 1, 2, 4, 8, 16, and so on. 1011 is 8 + 2 + 1 = 11. Hex is shorter to write, so people use it for bytes and masks: ff is 1111 1111, which is 255.

DecimalBinaryHex
501015
101010a
151111f
2551111 1111ff
2561 0000 0000100

JavaScript has literals for each base and two conversions:

JavaScript
console.log(0b1011, 0xff, 0o17, (255).toString(2), (255).toString(16), (-5).toString(2));console.log(parseInt("1011", 2), parseInt("ff", 16), Number("0b101"));console.log((5).toString(2).padStart(8, "0"));

Output:

TEXT
11 255 15 11111111 ff -10111 255 500000101

Look at (-5).toString(2). It gives -101, which is a minus sign and the binary of 5. It does not show how the number is stored. To see the stored bits you must mask the number to a fixed width, which is the next section.

Two's complement: how negative numbers are stored

With 8 bits there are 256 patterns. Two's complement uses the top bit with weight -128 instead of +128. So 1111 1011 is -128 + 64 + 32 + 16 + 8 + 2 + 1 = -5. To negate a number: flip every bit, then add 1. That is -x === ~x + 1.

JavaScript
const bits8 = (n) => (n & 0xff).toString(2).padStart(8, "0");for (const n of [5, -5, 127, -128, -1]) console.log(String(n).padStart(4), bits8(n));
TEXT
   5 00000101  -5 11111011 127 01111111-128 10000000  -1 11111111

Why people like it: addition works the same for signed and unsigned numbers, so the hardware needs one adder. There is only one zero. The range for n bits is -2^(n-1) to 2^(n-1) - 1. For 8 bits that is -128 to 127, so there is one more negative number than positive ones. (That is why negating -128 gives -128 again in 8 bits.)

BitsSigned rangeUnsigned range
8-128 to 1270 to 255
16-32,768 to 32,7670 to 65,535
32-2,147,483,648 to 2,147,483,6470 to 4,294,967,295

Fixed-width typed arrays show wrap-around directly:

JavaScript
const i8 = new Int8Array([127]); i8[0] += 1; console.log("Int8 wrap", i8[0]);const u8 = new Uint8Array([0]); u8[0] -= 1; console.log("Uint8 wrap", u8[0]);console.log(-5 === ~5 + 1, ~5, ~-1, ~0);
TEXT
Int8 wrap -128Uint8 wrap 255true -6 0 -1

~x flips all bits, so ~x === -x - 1. This is why ~-1 is 0, and why old code wrote if (~str.indexOf("a")) to mean "found". Do not copy that style. Write str.includes("a").

The same bits can be signed or unsigned. A value is only a number once you choose how to read it. The bits 1111 1111 1111 1110 are 65534 as unsigned 16-bit and -2 as signed 16-bit. We will see this again with DataView.

JavaScript bitwise operators use 32-bit signed integers

A plain JavaScript number is a 64-bit float (Lesson 2). The bitwise operators do not work on floats. They first convert each operand to a 32-bit signed integer (the spec calls this ToInt32), do the operation, and give back a 32-bit result. This has four effects:

  1. The fraction is cut off (towards zero). Numbers outside the 32-bit range lose their high bits.
  2. Anything that is not a number is converted first. NaN and Infinity become 0.
  3. The result of &, |, ^, ~, << and >> is signed, so the top bit shows up as a negative number.
  4. For shifts, the shift count is taken modulo 32. So 1 << 32 is 1 << 0.
JavaScript
console.log(2 ** 31 | 0, 2 ** 32 | 0, (2 ** 32 + 5) | 0, 1 << 31, 1 << 32, 1 << 33);console.log(5.9 | 0, -5.9 | 0, Math.floor(-5.9), 2 ** 53 | 0, "abc" | 0);console.log(2 ** 40 >> 1, Math.floor(2 ** 40 / 2));
TEXT
-2147483648 0 5 -2147483648 1 25 -5 -6 0 00 549755813888

Read the last line: 2 ** 40 >> 1 is 0 because the low 32 bits of 2^40 are all zero. Dividing with Math.floor(x / 2) is correct. So never use >> 1 for halving when the value may exceed 2^31. A classic bug is a binary search written as (lo + hi) >> 1 when the array has more than 2^30 items.

Also note 5.9 | 0 is 5 and -5.9 | 0 is -5: it truncates toward zero, like Math.trunc. Math.floor(-5.9) is -6, which is different. MDN says to prefer Math.trunc over these no-op tricks, because they silently break outside the 32-bit range.

>> versus >>>

  • >> is the arithmetic (sign-keeping) right shift. It copies the top bit into the empty places. It is like division by 2 that rounds toward minus infinity.
  • >>> is the logical (unsigned) right shift. It puts zeros in. The result is read as an unsigned 32-bit number, 0 to 4,294,967,295.
JavaScript
console.log(-1 >> 1, -1 >>> 1, -1 >>> 0, -8 >> 1, -8 >>> 28);
TEXT
-1 2147483647 4294967295 -4 15

x >>> 0 is the usual way to turn a signed 32-bit result into an unsigned number. You need it when a bit operation sets the top bit and you want a positive number back, such as hash values and IP addresses:

JavaScript
console.log(1 << 31, (1 << 31) >>> 0, 1 << 32);console.log(0xffffffff & 0xffffffff, (0xffffffff & 0xffffffff) >>> 0);
TEXT
-2147483648 2147483648 1-1 4294967295

>>> does not exist in a useful form for BigInt, because a BigInt has no fixed width. Use BigInt.asUintN(bits, x) and BigInt.asIntN(bits, x) to cut a BigInt to a fixed width:

JavaScript
console.log(typeof (5n & 3n), 5n << 40n, BigInt.asUintN(8, -1n), BigInt.asIntN(8, 255n));
TEXT
bigint 5497558138880n 255n -1n

Operator precedence trap. === and + bind tighter than & and |, and + binds tighter than shifts. In my run 5 & 1 === 1 printed 1, because it is 5 & (1 === 1), which is 5 & true. And 1 + 2 << 3 is 24, because the addition happens first. Always put parentheses around bit operations: (x & 1) === 1.

Masks and flags

A mask picks bits. Four basic moves cover most of what you will write:

JavaScript
const get = (x, i) => (x >> i) & 1;const set = (x, i) => x | (1 << i);const clear = (x, i) => x & ~(1 << i);const toggle = (x, i) => x ^ (1 << i);let x = 0b1010;console.log(get(x, 1), get(x, 0), set(x, 0).toString(2), clear(x, 1).toString(2), toggle(x, 3).toString(2));console.log((0xabcd & 0xff).toString(16), ((0xabcd >> 8) & 0xff).toString(16));
TEXT
1 0 1011 1000 10cd ab
GoalExpressionIdea
Read bit i(x >> i) & 1move it to the bottom, then keep one bit
Set bit ix | (1 << i)OR with 1 forces it on
Clear bit ix & ~(1 << i)AND with 0 forces it off
Toggle bit ix ^ (1 << i)XOR with 1 flips it
Keep the low k bitsx & ((1 << k) - 1)works for k up to 31
Is it odd(x & 1) === 1the lowest bit is the parity

Common tricks

The five tricks below show up often. Each one has a short reason, and that reason matters more than the memorised line.

JavaScript
const isPow2 = (n) => n > 0 && (n & (n - 1)) === 0;console.log([0, 1, 2, 3, 16, 18, 1024].map(isPow2).join(" "));const lowbit = (n) => n & -n;console.log(lowbit(12), lowbit(40), lowbit(7), (12).toString(2), (-12 >>> 0).toString(2));const clearLow = (n) => n & (n - 1);console.log(clearLow(0b101100).toString(2));
TEXT
false true true false true false true4 8 1 1100 11111111111111111111111111110100101000
  • n & (n - 1) clears the lowest set bit. Subtracting 1 turns the lowest 1 into 0 and every 0 below it into 1. Then AND removes the lowest 1. A power of two has exactly one set bit, so the result is 0. Check n > 0 first, because 0 would also pass.
  • n & -n keeps only the lowest set bit. Look at the printed bits of 12 and -12: ...1100 and ...0100. They agree only at the lowest set bit. Fenwick trees (binary indexed trees) use this.
  • Counting set bits (popcount) by repeating n &= n - 1 loops once per set bit, not once per bit:
JavaScript
function popcount(n) { let c = 0; n >>>= 0; while (n) { n &= n - 1; c++; } return c; }console.log(popcount(0), popcount(255), popcount(-1), popcount(0b101101));
TEXT
0 8 32 4

Math.clz32 counts the leading zero bits of the 32-bit form, so the highest set bit is at position 31 - Math.clz32(n). JavaScript has no built-in popcount. Math.imul multiplies as 32-bit integers (Math.imul(65536, 65536) is 0, while 65536 * 65536 is 4294967296), which hash functions use to wrap like C.

  • Swap with XOR. a ^= b; b ^= a; a ^= b; swaps two variables without a third. XOR is its own inverse, so this works. It is an interview curiosity. In real code, write [a, b] = [b, a], which is clearer. If the two names refer to the same slot (for example arr[i] and arr[j] with i === j), the XOR swap sets the value to 0.
JavaScript
let a = 7, b = 9; a ^= b; b ^= a; a ^= b; console.log(a, b);let p = 5; p ^= p; console.log(p);
TEXT
9 70
  • Find the single number. XOR of a value with itself is 0, and XOR with 0 changes nothing. So XOR over a list where every value appears twice except one gives that one value, with O(1) extra memory:
JavaScript
const arr = [3,3,4,4,9,5,5]; console.log(arr.reduce((s, v) => s ^ v, 0));
TEXT
9
  • Modulo a power of two. x & (m - 1) equals x % m when m is a power of two and x is not negative. Hash tables use this to pick a bucket:
JavaScript
console.log(77 & (16 - 1), 77 % 16, 7 & 1, 8 & 1, -7 & 1, -7 % 2);
TEXT
13 13 1 0 1 -1

Note -7 & 1 is 1 (odd) but -7 % 2 is -1. So the test x % 2 === 1 fails for negative odd numbers; use x % 2 !== 0 or (x & 1) === 1.

For the interview problems that use these (Single Number, Number of 1 Bits, Counting Bits, Subsets), go to DSA Lesson 58.

A set of n items has 2^n subsets. Count m from 0 to 2^n - 1, and let bit i of m say whether item i is in. That is the subsets-with-bitmask problem in DSA Lesson 58.

Bit sets for permissions

A flag set packs many yes/no values in one integer. It is small, fast to compare, and easy to store in a database column or a token.

JavaScript
const P = { READ: 1 << 0, WRITE: 1 << 1, DELETE: 1 << 2, ADMIN: 1 << 3 };let perms = P.READ | P.WRITE;const has = (p, f) => (p & f) === f;console.log(has(perms, P.READ), has(perms, P.DELETE), has(perms, P.READ | P.WRITE));perms |= P.DELETE; perms &= ~P.WRITE;console.log(perms.toString(2).padStart(4, "0"), Object.entries(P).filter(([, v]) => perms & v).map(([k]) => k));
TEXT
true false true0101 [ 'READ', 'DELETE' ]

Notes that matter in a design review:

  • Test with (p & f) === f when f may hold several flags. (p & f) !== 0 means "any of them".
  • The numbers are part of your stored data. Never reorder or reuse a flag value once it is in a database. Add new flags at the end.
  • 31 flags is the limit with plain operators. For more, use a BigInt, or an array of 32-bit words:
JavaScript
class BitSet { constructor(n) { this.w = new Uint32Array((n + 31) >>> 5); }  add(i) { this.w[i >>> 5] |= 1 << (i & 31); } has(i) { return (this.w[i >>> 5] >>> (i & 31) & 1) === 1; } }const bs = new BitSet(100); bs.add(3); bs.add(64); bs.add(99);console.log(bs.has(3), bs.has(4), bs.has(64), bs.has(99), bs.w.length, bs.w.byteLength);
TEXT
true false true true 4 16

One hundred flags take 16 bytes. A dense, contiguous block like this is also friendly to the CPU cache (Lesson 1).

  • A bit set is less readable than a list of role names, and harder to query in SQL. Use it when size or speed matters, or when the data is a protocol format. For a normal "roles" table, plain names are usually better.

Endianness and byte order

A multi-byte number must be put in memory in some byte order. For the 32-bit value 0x12345678:

OrderBytes in memory, lowest address first
Big-endian12 34 56 78
Little-endian78 56 34 12

Most desktop and phone CPUs you will meet (x86-64, and ARM in its usual setting) are little-endian. Network protocols (IP, TCP, and many others) put numbers in big-endian order, so big-endian is called network byte order. Files have their own rule; each format says which order it uses.

In JavaScript, a DataView lets you choose the order for each read and write. The last argument is littleEndian, and the default is big-endian. Typed arrays like Uint32Array use the host order, which makes them risky for file and network formats.

JavaScript
import os from "node:os";const hex = (u8) => [...u8].map((b) => b.toString(16).padStart(2, "0")).join(" ");const buf = new ArrayBuffer(4);const dv = new DataView(buf);const u8 = new Uint8Array(buf);dv.setUint32(0, 0x12345678);            // default is big endianconsole.log("BE:", hex(u8));dv.setUint32(0, 0x12345678, true);      // true = little endianconsole.log("LE:", hex(u8));console.log(dv.getUint32(0, true).toString(16), dv.getUint32(0, false).toString(16));console.log("host:", os.endianness(), hex(new Uint8Array(new Uint32Array([0x12345678]).buffer)));
TEXT
BE: 12 34 56 78LE: 78 56 34 1212345678 78563412host: LE 78 56 34 12

My machine is little-endian, so the Uint32Array stored 78 56 34 12. On a big-endian host it would store 12 34 56 78.

Reading the same four bytes in the wrong order gives a wrong number with no error. This is one of the most common bugs when you parse a binary format:

JavaScript
const bytes = new Uint8Array([0x00, 0x00, 0x01, 0xf4]);console.log((bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0);console.log(new DataView(bytes.buffer).getUint32(0), new DataView(bytes.buffer).getUint32(0, true));
TEXT
500500 4093706240

Now a small binary packet: 1 byte version, 2 bytes length, 4 bytes id, all in network order. Then the signed versus unsigned view of the same bytes:

JavaScript
const pkt = new DataView(new ArrayBuffer(7));pkt.setUint8(0, 1); pkt.setUint16(1, 500); pkt.setUint32(3, 0xdeadbeef);console.log(hex(new Uint8Array(pkt.buffer)), pkt.getUint16(1), pkt.getUint32(3).toString(16));const s = new DataView(new Uint8Array([0xff, 0xfe]).buffer);console.log(s.getInt16(0), s.getUint16(0), s.getInt8(0), s.getUint8(0));
TEXT
01 01 f4 de ad be ef 500 deadbeef-2 65534 -1 255

Text is a different case: in UTF-8 the byte order of each character is fixed, so UTF-8 has no endianness. UTF-16 does need an order for each 16-bit unit; see Lesson 3. Floats are stored as bytes too (Lesson 2).

Common mistakes

  1. Using bitwise operators on numbers above 2^31 (or 2^32). The high bits are dropped without any error.
  2. Forgetting >>> 0 and printing a negative number where you wanted unsigned.
  3. >> 1 for halving a value that may be negative or large.
  4. Missing parentheses around & and | next to === or +.
  5. Assuming the host byte order when reading a file or socket. Use DataView with an explicit order.
  6. Reusing or reordering flag values that are already stored.
  7. Using a typed array on bytes of the wrong size. new Uint16Array(new Uint8Array([1,2,3]).buffer) threw RangeError: byte length of Uint16Array should be a multiple of 2 when I ran it. DataView reads any offset.

Interview questions

How is -5 stored in 8 bits? (Must know)

In two's complement. Take 5, which is 00000101, flip all bits to get 11111010, then add 1 to get 11111011. Another way to read it: the top bit has weight -128, so 11111011 is -128 + 123 = -5. I like to say why it is used: addition and subtraction work the same for signed and unsigned values, and there is only one zero.

What is the range of a signed 32-bit integer, and what happens past it? (Must know)

-2,147,483,648 to 2,147,483,647. In C-like languages, signed overflow can be undefined, and unsigned overflow wraps. In JavaScript, plain numbers are floats and do not overflow here, but the bitwise operators convert to 32-bit first. So 2 ** 32 + 5 | 0 is 5, and 2 ** 31 | 0 is -2147483648. For exact 64-bit math I use BigInt with asIntN or asUintN.

What is the difference between >> and >>>? (Must know)

>> keeps the sign by copying the top bit in from the left. >>> fills with zeros and gives an unsigned result. -1 >> 1 is -1, but -1 >>> 1 is 2147483647. I use x >>> 0 to convert a signed 32-bit result into an unsigned number.

Why does 1 << 32 equal 1 in JavaScript? (Senior signal)

The shift count is converted to an unsigned 32-bit integer and then taken modulo 32. So 32 becomes 0, and 1 << 33 is 1 << 1, which is 2. It is a trap when the width is a variable: the code works for 31 and silently breaks at 32. For widths of 32 or more, I use BigInt, or I split the number across several 32-bit words.

How do you check if a number is a power of two? (Must know)

n > 0 && (n & (n - 1)) === 0. A power of two has exactly one set bit. Subtracting 1 flips that bit to 0 and sets all lower bits to 1, so the AND is 0. The n > 0 check is needed, because 0 would pass. Also, it breaks above 32 bits: in my run it said true for 2 ** 32 + 1, because only the low 32 bits are looked at. For larger values I use BigInt: n > 0n && (n & (n - 1n)) === 0n.

How would you count the set bits in an integer? (Must know)

Loop with n &= n - 1, which removes the lowest set bit each time, and count the loops. It takes as many steps as there are set bits. I start with n >>>= 0 so the value is a clean 32-bit number. A 256-entry table lookup per byte is an alternative for a hot path (I did not measure it). JavaScript has no built-in popcount, but I would first check whether it is a hot path at all.

Swap two variables without a third one. Should you? (Trap)

With XOR: a ^= b; b ^= a; a ^= b;. It works because XOR is its own inverse. But I would not use it in real code: destructuring ([a, b] = [b, a]) is clearer and the engine handles it well. There is a hidden bug too: if both names point to the same array slot, the XOR version sets it to 0. I mention that to show I know the trick but also its cost.

Every number appears twice except one. Find it in O(1) memory. (Senior signal)

XOR all the numbers. Equal pairs cancel to 0, and 0 XOR x is x. It is O(n) time and O(1) space. It relies on the fact that XOR is commutative and associative, so the order does not matter. If the question changes to "every number appears three times except one", the trick needs a bit-counting version: count each bit position modulo 3. The problem list is in DSA Lesson 58.

What is endianness and when does it matter? (Must know)

It is the order of the bytes of a multi-byte number in memory or in a file. Big-endian puts the most significant byte first, little-endian puts the least significant first. It matters only when bytes cross a boundary: reading a file, a socket, or a typed array view over data from another system. Inside a program, you do not see it. On the network, the standard is big-endian, called network byte order. In JavaScript I use DataView and pass the order on every call, because DataView defaults to big-endian but typed arrays use the host order.

How do you read a 4-byte big-endian length from a Uint8Array in JavaScript? (Must know)

new DataView(u8.buffer, u8.byteOffset, u8.byteLength).getUint32(0) with the default big-endian. Or by hand: (b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]) >>> 0. The >>> 0 is needed, because if the top byte is 128 or more the result would be negative. I pass byteOffset because a Uint8Array can be a view into the middle of a larger buffer, and u8.buffer alone would read the wrong place.

Design a permission system with flags. What are the risks? (Senior signal)

Each permission is one bit: READ = 1 << 0, WRITE = 1 << 1, and so on. Stored as an integer, checked with (p & f) === f. Risks: the values become part of the stored data, so never reorder or reuse them; plain JavaScript operators stop at 31 flags, so I would use BigInt or a Uint32Array beyond that; and SQL queries need bit operators, which are harder to read and may not use an index. For a small, stable set of flags it is neat. For a large or changing set, a join table of named permissions is easier to audit.

Why is x % 2 === 1 wrong for odd detection? (Trap)

For negative numbers, % keeps the sign of the left side, so -7 % 2 is -1, not 1, and the test fails. x % 2 !== 0 works, and (x & 1) === 1 works too, because in two's complement the lowest bit of -7 is 1. Both work only for integers; 0.5 & 1 is 0 because the fraction is cut first.

Key takeaways

  • Binary is base 2, and one hex digit is exactly four bits. Two's complement gives -x === ~x + 1, one zero, and the same adder for signed and unsigned.
  • JavaScript bitwise operators work on 32-bit signed integers. Larger numbers lose their high bits, shift counts are taken modulo 32, and >>> 0 makes the result unsigned.
  • >> keeps the sign, >>> does not. Use Math.floor or Math.trunc for arithmetic, and BigInt for wide values.
  • The tricks all come from n & (n - 1) (clear the lowest bit), n & -n (keep the lowest bit) and XOR cancelling itself.
  • Endianness is byte order. Network order is big-endian. Use DataView with an explicit order for any file or network format.
  • Flags are compact but become stored data. Never reorder them, and watch the 31-flag limit.
  • Always use parentheses around bitwise operators.

What's next

Lesson 5: Combinatorics and Probability for Interviews. Counting and chance are the next set of basics that interviewers expect without explaining, and you can check every answer by simulation. Before that, solve the problems in DSA Lesson 58 using the machinery from this lesson.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related series