DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Build a CIDR Subnet Calculator in JavaScript with Nothing but Bitwise Math

192.168.1.100 looks like four numbers. It is one 32-bit integer written in a friendly way — four bytes, biggest first. The /24 after it is not an address either; it is a count of bits: the first 24 identify the network, the remaining 8 identify a host inside it.

Once you hold that, every question a subnet calculator answers is one or two bitwise operations. No library, no lookup tables, no ranges to iterate. Here is the whole thing.

Dotted quad in, one integer out

Shift each octet into place and OR them together. Reverse it with shifts and a & 255 mask. Validate hard: an octet above 255 does not fit in its byte, and a leading zero like 010 is genuinely ambiguous because some tools read it as octal 8.

function parseIp(s) {
  const m = String(s).trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
  if (!m) return null;
  const parts = m.slice(1);
  if (parts.some(p => p.length > 1 && p[0] === "0")) return null;   // reject 010
  const o = parts.map(Number);
  if (o.some(v => v > 255)) return null;
  return ((o[0] << 24) | (o[1] << 16) | (o[2] << 8) | o[3]) >>> 0;
}

function toIp(n) {
  n = n >>> 0;
  return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
}
Enter fullscreen mode Exit fullscreen mode

parseIp("192.168.1.100") is 3232235876. Everything below operates on that number.

The mask is the prefix, drawn as bits

A netmask is p ones followed by 32 - p zeros. Build it by taking all-ones and shifting left by the number of host bits — the ones fall off the top, zeros arrive at the bottom.

There is one trap. JavaScript takes shift counts modulo 32, so shifting by 32 (what /0 asks for) is really a shift by 0 and returns all-ones instead of zero. Hence the explicit case:

function maskOf(p)     { return p === 0 ? 0 : (0xFFFFFFFF << (32 - p)) >>> 0; }
function wildcardOf(p) { return (~maskOf(p)) >>> 0; }

maskOf(24)      // "255.255.255.0"
wildcardOf(24)  // "0.0.0.255"
Enter fullscreen mode Exit fullscreen mode

AND gives the network, OR gives the broadcast

AND-ing an address with its mask keeps every network bit and forces every host bit to zero: that is the network address, the first address of the block and its name. OR with the wildcard forces every host bit to one and you have the last address, the broadcast.

const networkOf   = (ip, p) => (ip & maskOf(p)) >>> 0;
const broadcastOf = (ip, p) => (networkOf(ip, p) | wildcardOf(p)) >>> 0;
Enter fullscreen mode Exit fullscreen mode

These two lines are the heart of it. They are also the normaliser: 192.168.1.70/26 is not really a block, and masking snaps it back to 192.168.1.64/26, which is the block whoever wrote it meant.

The signed-integer trap

Worth its own section, because it is the single most common bug in hand-written IP code. JavaScript numbers are doubles, but the bitwise operators convert to a signed 32-bit integer first. The moment the top bit is set — every address from 128.0.0.0 up, and every netmask — results come back negative:

(0xFFFFFFFF << 8)          // -256        signed, useless
(0xFFFFFFFF << 8) >>> 0    // 4294967040  the same bits, read unsigned
Enter fullscreen mode Exit fullscreen mode

>>> 0 is the one operator that returns an unsigned result. The rule: do the bitwise work, then finish every expression with >>> 0 before you compare, print or store it.

Counting hosts, and the two exceptions

A block holds 2^(32-p) addresses, minus two that are traditionally not assignable — the all-zeros host part names the network, the all-ones part is the broadcast. So a /24 gives 254 usable hosts, not 256.

Two prefixes break that rule for good reasons:

function usableOf(p) {
  if (p === 32) return 1;                 // a host route: one machine
  if (p === 31) return 2;                 // RFC 3021 point-to-point
  return Math.pow(2, 32 - p) - 2;
}
Enter fullscreen mode Exit fullscreen mode

A /31 has only two addresses and RFC 3021 says both are usable, because a link with exactly two ends never needs to broadcast. It saves two addresses on every router-to-router hop. Note Math.pow rather than 1 << n: a /0 holds 2^32 addresses, which overflows a 32-bit shift.

Membership is one masked comparison

"Is 10.1.2.3 inside 10.0.0.0/8?" needs no loop over a range. Mask the candidate with the block's mask and compare — if the network bits agree it is inside, because the only thing that can differ is the host part.

function contains(netIp, p, ip) {
  return networkOf(ip, p) === networkOf(netIp, p);
}
Enter fullscreen mode Exit fullscreen mode

This is exactly what a router does against its forwarding table, millions of times a second, and why firewall rules are written as CIDR blocks rather than address lists.

Two blocks nest or miss — never half-overlap

Reduce each block to the interval [network, broadcast] and the classic interval test applies. But CIDR gives you something stronger: because every block is a power-of-two size aligned to a multiple of that size, two blocks can never partially overlap. They are identical, one wholly contains the other, or they are disjoint.

function relation(a, b) {
  const an = networkOf(a.ip, a.p), ab = broadcastOf(a.ip, a.p);
  const bn = networkOf(b.ip, b.p), bb = broadcastOf(b.ip, b.p);
  if (an === bn && a.p === b.p)       return "identical";
  if (an <= bn && bb <= ab)           return "a contains b";
  if (bn <= an && ab <= bb)           return "b contains a";
  if (an <= bb && bn <= ab)           return "overlap";
  if (ab + 1 === bn || bb + 1 === an) return "adjacent";
  return "disjoint";
}
Enter fullscreen mode Exit fullscreen mode

That nesting property is what makes route aggregation possible: an ISP advertises one /16 instead of 256 separate /24s.

Subnetting is borrowing host bits

Carving a /24 into four pieces moves the line two bits right, to /26. The two borrowed bits count the children (2² = 4); the six left over size each child (2⁶ = 64 addresses). Walk from the parent network in steps of the child size — because the step is a power of two, every child lands aligned automatically.

function split(ip, p, q) {
  const base  = networkOf(ip, p);
  const total = Math.pow(2, q - p);      // how many children
  const step  = Math.pow(2, 32 - q);     // child block size
  const out = [];
  for (let i = 0; i < total; i++) out.push((base + i * step) >>> 0);
  return out;
}
// /24 -> /26 : .0, .64, .128, .192
Enter fullscreen mode Exit fullscreen mode

The cost shows up immediately: four /26s lose eight addresses, because each child now spends its own network and broadcast. That trade — more, smaller broadcast domains against wasted addresses — is the whole art of subnet design.

Any range to the shortest CIDR list

Real input is often a range, not a block: "allow 192.168.1.5 through 192.168.1.10". That is not a single CIDR, so it must be covered by several. Stand at the start address and grow the biggest block that both starts there (the address must be aligned for that prefix) and ends at or before the range end. Emit it, jump past it, repeat.

function rangeToCidrs(start, end) {
  const out = [];
  let s = start >>> 0;
  while (s <= end) {
    let p = 32;
    while (p > 0) {                                   // try one bit bigger
      const m = maskOf(p - 1);
      if (((s & m) >>> 0) !== s) break;               // would not be aligned
      if (((s | (~m >>> 0)) >>> 0) > end) break;      // would overshoot
      p--;
    }
    out.push({ base: s, prefix: p });
    const last = (s | wildcardOf(p)) >>> 0;
    if (last >= end) break;                           // also guards 255.255.255.255
    s = last + 1;
  }
  return out;
}

// .5 - .10  ->  192.168.1.5/32, 192.168.1.6/31, 192.168.1.8/31, 192.168.1.10/32
Enter fullscreen mode Exit fullscreen mode

Greedy is provably optimal here, again because of alignment: at any position exactly one largest legal block exists. Watch the exit condition — comparing against the last address instead of incrementing past it is what stops 255.255.255.255 from wrapping around to 0.

Special ranges are just more CIDR blocks

Knowing these saves hours of debugging. 10/8, 172.16/12 and 192.168/16 are private (RFC 1918) and must be NAT-ed. 127/8 is loopback. 169.254/16 is link-local — the address a machine gives itself when DHCP never answered. 100.64/10 is carrier-grade NAT, which is why your "public" IP sometimes is not. 192.0.2/24, 198.51.100/24 and 203.0.113/24 are reserved for documentation, so use those in examples instead of someone's real address.

Each entry is itself a block, so the classifier is the contains() above run down an ordered table, most specific first:

const SPECIAL = [
  ["10.0.0.0",     8, "private (RFC 1918)"],
  ["100.64.0.0",  10, "carrier-grade NAT (RFC 6598)"],
  ["127.0.0.0",    8, "loopback"],
  ["169.254.0.0", 16, "link-local (APIPA)"],
  ["172.16.0.0",  12, "private (RFC 1918)"],
  ["192.0.2.0",   24, "documentation TEST-NET-1"],
  ["192.168.0.0", 16, "private (RFC 1918)"],
  ["224.0.0.0",    4, "multicast"]
];

function classify(ip) {
  for (const [net, p, label] of SPECIAL)
    if (contains(parseIp(net), p, ip)) return label;
  return "public — globally routable";
}
Enter fullscreen mode Exit fullscreen mode

The class A/B/C letters that predate all this fixed the prefix at /8, /16 or /24 by first octet. It wasted addresses so fast that CIDR replaced it in 1993 — the class is now a historical label, nothing more.

Prove it

The maths is checkable by hand, so assert the textbook cases and a refactor can never quietly break them:

console.assert(toIp(maskOf(24)) === "255.255.255.0",                            "mask /24");
console.assert(toIp(networkOf(parseIp("172.16.5.4"), 22))   === "172.16.4.0",   "network /22");
console.assert(toIp(broadcastOf(parseIp("172.16.5.4"), 22)) === "172.16.7.255", "broadcast /22");
console.assert(usableOf(24) === 254 && usableOf(31) === 2 && usableOf(32) === 1, "counts");
console.assert(rangeToCidrs(parseIp("192.168.1.5"), parseIp("192.168.1.10")).length === 4, "range");
Enter fullscreen mode Exit fullscreen mode

A stronger one is worth adding: generate every start/end pair in a small window, run rangeToCidrs on each, and assert the emitted blocks are aligned, contiguous, and cover the range exactly — no gaps, no overlap.

The whole calculator is &, |, ~ and >>> 0 on one integer. Drag the prefix slider, split a block, and watch all 32 bits light up here: https://dev48v.infy.uk/solve/day59-cidr-subnet-calculator.html

Top comments (0)