Every Kubernetes / VPC / AWS person has had the moment where they need to split a
/16into its component/24s andipcalcisn'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<< 32shift-amount mod-32 trap on/0, and RFC 3021's/31exception (the one most olderipcalcversions get wrong).
🌐 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
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; // ←
// ...
}
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)
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;
}
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);
});
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:
-
/32is a single-host route (loopback,127.0.0.1/32; Kubernetes Cluster IPs).network = broadcast = first = last,usable = 1. -
/31is 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 olderipcalcversions reportusable = 0for/31because 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;
}
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");
});
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
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");
});
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 };
}
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;
}
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);
});
TL;DR
-
>>> 0after every bitwise op or your uint32 becomes a negative int32. -
Special-case
/0inmaskFor— JS's<<operator applies its right-hand side mod 32, so<< 32and<< 0are the same. -
/31has 2 usable addresses (RFC 3021);/32has 1. Olderipcalcversions report 0 for/31. -
Mask host bits in
cidrInfo, so a user pasting10.0.5.7/24lands on the right network. -
Cap subdivide output so
/8 → /30doesn't try to materialise 4 M items into the DOM. -
Regex-check octets in
ipToInt—Number()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)