DEV Community

SEN LLC
SEN LLC

Posted on

A Browser IPv4 CIDR Calculator — Three JavaScript Gotchas (uint32 Coercion, /0 Shift Trap, RFC 3021 /31)

Every Kubernetes / VPC / AWS person has had the moment where they need to split a /16 into its component /24s and ipcalc isn't on the laptop. This is the 350-line browser tool for that — paste a CIDR, see the network, broadcast, usable range, mask, wildcard, and the optional subdivision. The implementation is straightforward except for three JavaScript-specific traps the article walks through: uint32 coercion via >>> 0, the << 32 shift-amount mod-32 trap on /0, and RFC 3021's /31 exception (the one most older ipcalc versions get wrong).

cidr-calc UI: dark theme. Top: CIDR input field with

🌐 Demo: https://sen.ltd/portfolio/cidr-calc/
📦 GitHub: https://github.com/sen-ltd/cidr-calc

Gotcha 1: bit ops produce signed int32, coerce with >>> 0

JavaScript numbers are IEEE-754 doubles. Bit operators internally treat the value as int32, do the op, and convert back. The conversion to int32 keeps the sign bit as a sign bit — so the moment your top bit is set, what should be a uint32 like 0xFFFFFFFF (4,294,967,295) becomes the int32 -1:

0xFFFFFFFF                 // → 4294967295 — looks unsigned, fine
0xFFFFFFFF << 8            // → -256 — top bits got dropped, the result is int32
(0xFFFFFFFF << 8) >>> 0    // → 4294967040 — explicit uint32 coercion
Enter fullscreen mode Exit fullscreen mode

The >>> 0 (unsigned right-shift by zero) is the idiom for "force this back to uint32". The whole module disciplines itself to slap >>> 0 after every bitwise operation:

export function maskFor(prefix) {
  if (prefix === 0) return 0;
  return (0xFFFFFFFF << (32 - prefix)) >>> 0;  // ←
}

export function cidrInfo(ip, prefix) {
  const mask = maskFor(prefix);
  const network = (ip & mask) >>> 0;                       // ←
  const broadcast = (network | (~mask >>> 0)) >>> 0;       // ←
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Without the coercion, intToIp reads the high bits as negative shifts and the dotted-quad comes out garbage.

Gotcha 2: << 32 is << 0, special-case /0

To build a netmask for prefix length N, the obvious code is 0xFFFFFFFF << (32 - N). For N = 32 you get << 0 which is fine. For N = 0 you get << 32 — which JavaScript silently treats as << 0 because the spec says the shift amount is taken modulo 32:

0xFFFFFFFF << 32  // → -1 (i.e. 0xFFFFFFFF, the same value)
Enter fullscreen mode Exit fullscreen mode

So maskFor(0) written naïvely returns the same mask as /32, which is exactly wrong (/0 should be all-zeros, the empty mask). The fix is a one-line branch:

export function maskFor(prefix) {
  if (prefix === 0) return 0;
  return (0xFFFFFFFF << (32 - prefix)) >>> 0;
}
Enter fullscreen mode Exit fullscreen mode

Pinned at both ends in the test:

test("maskFor handles the boundary prefixes 0 and 32", () => {
  assert.equal(maskFor(0), 0);              // ← without the branch, this is 0xFFFFFFFF
  assert.equal(maskFor(32), 0xFFFFFFFF);
  assert.equal(maskFor(24), 0xFFFFFF00);
});
Enter fullscreen mode Exit fullscreen mode

Gotcha 3: RFC 3021 says /31 has TWO usable hosts

The classical "total - 2 = usable hosts" formula reserves the network and broadcast addresses. That's correct for /30 and wider, but produces wrong answers at the small end of the spectrum:

  • /32 is a single-host route (loopback, 127.0.0.1/32; Kubernetes Cluster IPs). network = broadcast = first = last, usable = 1.
  • /31 is a point-to-point link per RFC 3021 (March 2001). Both addresses are usable; no network/broadcast reserve. This is the standard for router-to-router links nowadays. Many older ipcalc versions report usable = 0 for /31 because they predate RFC 3021.
if (prefix === 32) {
  firstUsable = network;
  lastUsable = network;
  usable = 1;
} else if (prefix === 31) {
  firstUsable = network;
  lastUsable = broadcast;
  usable = 2;
} else {
  firstUsable = (network + 1) >>> 0;
  lastUsable = (broadcast - 1) >>> 0;
  usable = total - 2;
}
Enter fullscreen mode Exit fullscreen mode

Tests for both special cases:

test("cidrInfo handles /31 per RFC 3021 (no network/broadcast reserve)", () => {
  const info = cidrInfo(ipToInt("10.0.0.0"), 31);
  assert.equal(info.total, 2);
  assert.equal(info.usable, 2);          // ← not 0
  assert.equal(intToIp(info.firstUsable), "10.0.0.0");
  assert.equal(intToIp(info.lastUsable),  "10.0.0.1");
});

test("cidrInfo handles /32 as a single host", () => {
  const info = cidrInfo(ipToInt("10.0.0.5"), 32);
  assert.equal(info.usable, 1);
  assert.equal(intToIp(info.firstUsable), "10.0.0.5");
});
Enter fullscreen mode Exit fullscreen mode

Bonus: non-aligned input IPs are masked, not rejected

When a user pastes 10.0.5.7/24, the most sensible interpretation isn't "error: the host bits should be zero". It's "the user means the /24 that contains this address". Mask off the host bits inside cidrInfo:

const network = (ip & mask) >>> 0;     // ← drops the 7 bits below /24
Enter fullscreen mode Exit fullscreen mode

Now 10.0.5.7/24 and 10.0.5.0/24 produce the same output. Pinned:

test("cidrInfo zeros the host bits of a non-aligned input IP", () => {
  const info = cidrInfo(ipToInt("10.0.5.7"), 24);
  assert.equal(intToIp(info.network),   "10.0.5.0");
  assert.equal(intToIp(info.broadcast), "10.0.5.255");
});
Enter fullscreen mode Exit fullscreen mode

Subdivide with a truncate limit

Splitting /16 into /24 gives 256 subnets — fine to render. Splitting /8 into /24 gives 65,536; into /30 it's 4,194,304. Don't generate those eagerly:

export function subdivide(ip, prefix, newPrefix, limit = 256) {
  if (newPrefix <= prefix || newPrefix > 32) {
    throw new Error("newPrefix must be greater than current prefix and ≤ 32");
  }
  const mask = maskFor(prefix);
  const network = (ip & mask) >>> 0;
  const step = newPrefix === 32 ? 1 : (1 << (32 - newPrefix));
  const count = 1 << (newPrefix - prefix);
  const take = Math.min(count, limit);
  const subnets = [];
  for (let i = 0; i < take; i++) {
    subnets.push({ ip: (network + i * step) >>> 0, prefix: newPrefix });
  }
  return { subnets, total: count, truncated: count > take };
}
Enter fullscreen mode Exit fullscreen mode

The UI reads truncated: true and shows "first N of M shown" so the user knows the list isn't exhaustive. The newPrefix === 32 special-case for step avoids another instance of the 1 << 0 = 1 vs 1 << 32 = 1 ambiguity I noted earlier.

Reject malformed input loudly

ipToInt returns null on any malformation so the UI can show an error rather than computing garbage:

export function ipToInt(ip) {
  if (typeof ip !== "string") return null;
  const parts = ip.split(".");
  if (parts.length !== 4) return null;
  let n = 0;
  for (const part of parts) {
    if (!/^\d+$/.test(part)) return null;     // rejects "+5", "-1", "  10", "1.5"
    const v = Number(part);
    if (v < 0 || v > 255) return null;
    n = (n * 256) + v;
  }
  return n >>> 0;
}
Enter fullscreen mode Exit fullscreen mode

The /^\d+$/ test is stricter than Number(part) alone — Number(" 10") returns 10 (silently), Number("+5") returns 5, Number("1e3") returns 1000. None of those are valid octets. The regex catches them.

test("ipToInt rejects malformed input", () => {
  assert.equal(ipToInt("10.0.0"), null);          // wrong octet count
  assert.equal(ipToInt("10.0.0.0.0"), null);
  assert.equal(ipToInt("256.0.0.0"), null);       // octet > 255
  assert.equal(ipToInt("10.0.0.a"), null);
  assert.equal(ipToInt("10.0.0.-1"), null);       // sign char
  assert.equal(ipToInt(""), null);
});
Enter fullscreen mode Exit fullscreen mode

TL;DR

  • >>> 0 after every bitwise op or your uint32 becomes a negative int32.
  • Special-case /0 in maskFor — JS's << operator applies its right-hand side mod 32, so << 32 and << 0 are the same.
  • /31 has 2 usable addresses (RFC 3021); /32 has 1. Older ipcalc versions report 0 for /31.
  • Mask host bits in cidrInfo, so a user pasting 10.0.5.7/24 lands on the right network.
  • Cap subdivide output so /8 → /30 doesn't try to materialise 4 M items into the DOM.
  • Regex-check octets in ipToIntNumber() silently accepts " 10", "+5", "1e3", and other surprises.

Source: https://github.com/sen-ltd/cidr-calc — MIT, ~350 lines of JS, 22 unit tests, no build step, no dependencies.


🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)