If you haven't decided between H3 hexagons and Geohash rectangles yet, read our H3 vs Geohash vs S2 comparison first. This article is only about the next step: which npm package to install.
h3-js and ngeohash are the most downloaded JavaScript libraries for H3 and Geohash respectively. We use both in KunYu's GeoHash & H3 converter tool. Bundle size differs by 22x, encoding speed by 4–6x, and the production gotchas are completely different. Below are the measured numbers and the problems we only found after shipping.
Bundle Size: 86 KB vs 3.8 KB
Measured by gzipping the ES module entry points directly from node_modules:
| Metric | h3-js v4.4.0 | ngeohash v0.6.3 |
|---|---|---|
| ES module (gzipped) | 86 KB | 3.8 KB |
| Total package size | 6.5 MB | 52 KB |
| Runtime dependencies | 0 | 0 |
| Implementation | C → Emscripten | Pure JavaScript |
The gap comes down to implementation. h3-js is Uber's C library compiled through Emscripten — icosahedron projection, hexagonal grid traversal, polygon clipping all baked into one JS file, no tree-shaking possible. Importing just { latLngToCell } still ships the full 464 KB. ngeohash is 6 hand-written JS files — the entire library is smaller than h3-js's type definitions.
86 KB gzipped is roughly the size of React DOM. Doesn't matter on the server, but for a frontend project that only needs one encode call, that's another React DOM you're shipping for a single function.
Encoding Speed
Node.js v24, 100,000 iterations per operation with a 1,000-iteration warmup:
| Operation | h3-js | ngeohash | Gap |
|---|---|---|---|
| Encode (lat/lng → cell) | 850K ops/sec | 4.86M ops/sec | ngeohash 5.7x |
| Decode (cell → lat/lng) | 1.73M ops/sec | 7.58M ops/sec | ngeohash 4.4x |
| Neighbors (adjacent cells) | 614K ops/sec | 465K ops/sec | h3-js 1.3x |
| Boundary / BBox | 550K ops/sec | 9.29M ops/sec | ngeohash 17x |
The benchmark script (save as .mjs and run with node):
import { latLngToCell, cellToLatLng, gridDisk, cellToBoundary } from "h3-js";
import ngeohash from "ngeohash";
const ITERATIONS = 100_000;
function bench(name, fn) {
for (let i = 0; i < 1000; i++) fn(); // warmup
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) fn();
const ms = performance.now() - start;
const opsPerSec = Math.round((ITERATIONS / ms) * 1000);
console.log(`${name}: ${opsPerSec.toLocaleString()} ops/sec`);
}
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
const hash = ngeohash.encode(37.7749, -122.4194, 6);
bench("h3-js encode", () => latLngToCell(37.7749, -122.4194, 9));
bench("ngeohash encode", () => ngeohash.encode(37.7749, -122.4194, 6));
bench("h3-js decode", () => cellToLatLng(h3Cell));
bench("ngeohash decode", () => ngeohash.decode(hash));
bench("h3-js neighbors", () => gridDisk(h3Cell, 1));
bench("ngeohash neighbors", () => ngeohash.neighbors(hash));
bench("h3-js boundary", () => cellToBoundary(h3Cell));
bench("ngeohash bbox", () => ngeohash.decode_bbox(hash));
For simple encode/decode, ngeohash dominates — pure JS bit-interleaving operations are right in V8 JIT's sweet spot, no FFI overhead. h3-js pays marshaling cost on every call: arguments go into the Emscripten heap, C function runs, results get copied back to JavaScript.
But h3-js pulls ahead on neighbor search. gridDisk traverses the icosahedral grid in a tight C loop, and the compiled algorithm's efficiency outweighs the serialization cost.
The boundary row isn't a fair comparison: cellToBoundary computes 6 vertex coordinates for a hexagon, decode_bbox just returns 4 numbers. But these are the actual operations you'd call in production.
Unless you're processing millions of points per request, encoding speed won't decide which library to use. h3-js encodes a coordinate in 1.2 microseconds — fast enough.
API Comparison
Encode and Decode
import { latLngToCell, cellToLatLng } from "h3-js";
import ngeohash from "ngeohash";
// H3: resolution 9 ≈ 0.11 km²
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
// → "8928308280fffff"
// Geohash: precision 6 ≈ 0.74 km²
const hash = ngeohash.encode(37.7749, -122.4194, 6);
// → "9q8yyk"
// Decode
const [lat, lng] = cellToLatLng(h3Cell);
const { latitude, longitude } = ngeohash.decode(hash);
Neighbors
import { gridDisk } from "h3-js";
// H3: center + 6 hexagonal neighbors
const h3Neighbors = gridDisk(h3Cell, 1);
// Geohash: 8 neighbors (N, NE, E, SE, S, SW, W, NW)
const ghNeighbors = ngeohash.neighbors(hash);
Cell Boundary
import { cellToBoundary } from "h3-js";
// H3: 6 [lat, lng] vertices
const h3Boundary = cellToBoundary(h3Cell);
// Geohash: bounding box
const bbox = ngeohash.decode_bbox(hash);
// → [minLat, minLon, maxLat, maxLon]
decode_bbox returns [minLat, minLon, maxLat, maxLon] — south, west, north, east. Not the [west, south, east, north] order used by GeoJSON, and not the order Leaflet's LatLngBounds expects. Get it wrong and you'll get a bounding box on the other side of the planet — no error, just completely wrong results. Always destructure explicitly:
const [minLat, minLon, maxLat, maxLon] = ngeohash.decode_bbox(hash);
h3-js ships its own TypeScript type definitions; ngeohash requires a separate @types/ngeohash install.
Production Gotchas
H3 Cell IDs Silently Truncated by JSON Serialization
This one bit us the hardest. H3 cell IDs are 64-bit integers, exceeding JavaScript's safe integer range (2^53 − 1), so h3-js returns them as hex strings: "8928308280fffff".
The problem shows up at the API layer. If any part of your backend writes H3 IDs as JSON numbers instead of strings, JSON.parse silently truncates the value — you get a valid but completely different number, no error, and queries return empty results. We spent a full debugging session with data that just wouldn't match between services before tracing it back to serialization.
The fix is simple but must be thorough: string columns in the database, string fields in your API schema, string types in TypeScript interfaces. No exceptions. Don't assume "this ID's value isn't that large."
ngeohash Integer Encoding Capped at 52 Bits
encode_int and decode_int use integers instead of Base32 strings, but they're capped at 52 bits (the IEEE 754 float64 safe integer limit). Pass bitDepth above 52 and precision is silently lost — no error thrown.
Redis also uses 52-bit Geohash integers internally, so this aligns with the most common backend. But if you're porting logic from a Python or Java library that uses 64-bit Geohash integers, the values won't match, and it's hard to debug because the results "look close" — only the last few bits differ.
ngeohash and Other Geohash Libraries Don't Always Agree
Different Geohash implementations can produce different results for the same coordinate. The cause is floating-point rounding: when a coordinate falls exactly on the boundary between two cells, different libraries may round to different neighbors.
We hit this while building KunYu's Geohash tool — the frontend ngeohash output and the backend Python geohash library occasionally disagreed. It's rare, but real. For GPS coordinates (~3 meter accuracy) encoded into cells spanning hundreds of meters, the practical impact is minimal. But if you need Node.js and Python/Redis to agree exactly, write integration tests using coordinates on known cell boundaries. Redis's GEOHASH command is the safest reference implementation for cross-system consistency.
h3-js v3 → v4: Every Function Got Renamed
If your project is still on v3, upgrading to v4 means nearly every function name changes:
| v3 | v4 |
|---|---|
geoToH3 |
latLngToCell |
h3ToGeo |
cellToLatLng |
kRing |
gridDisk |
hexRing |
gridRingUnsafe |
compact |
compactCells |
v4 also changed error handling — invalid input throws instead of returning null. There's a compatibility layer at h3-js/legacy, but error behavior follows v4 semantics. A one-time find-and-replace migration is cleaner than depending on the legacy wrapper long-term. Full rename table in the v3→v4 migration guide.
Which One to Use
| Scenario | Pick | One-line reason |
|---|---|---|
| Redis GEO / Elasticsearch | ngeohash | Redis uses Geohash internally — matching behavior |
| Frontend proximity search | ngeohash | 3.8 KB vs 86 KB |
| Hexagonal heatmaps / Deck.gl | h3-js | Deck.gl natively supports H3HexagonLayer |
| Global aggregation (consistent cell area) | h3-js | H3 cell area varies <3% across latitudes |
| Human-readable IDs in logs | ngeohash | "9q8yyk" beats "8928308280fffff" |
| Polygon coverage / multi-resolution | h3-js | polygonToCells, compactCells, parent/child hierarchy |
| Server-side batch processing | Either | Both fast enough — pick based on index system, not library |
S2 in the JavaScript ecosystem is in rough shape: s2-geometry hasn't been updated in 8 years, nodes2ts is more complete but has a small community. If your project needs S2, the practical approach is a Python/Go microservice running the official library, called from your Node.js app.
To try Geohash and H3 encoding in the browser, use KunYu's GeoHash & H3 converter tool — paste coordinates, see the cell ID, and visualize boundaries on a map.
FAQ
Does h3-js use WebAssembly?
No. Despite being compiled from C, h3-js uses Emscripten to produce plain JavaScript rather than .wasm. The upside: no separate wasm file to load, works in all modern browsers. The downside: bundle size (86 KB gzipped) and call overhead are both higher than native wasm would be. The community has discussed a wasm build, but there's no official plan.
Does ngeohash have ESM exports?
No native ESM exports — the package is CommonJS only. Frontend bundlers (Vite, webpack) handle the CJS → ESM conversion automatically, so import ngeohash from "ngeohash" works fine in bundled projects. But if you're running a pure ESM Node.js project with "type": "module", you'll need createRequire or a community fork. The library has 100K+ weekly npm downloads but hasn't been published since 2020.
Can I use both libraries in the same project?
Yes, no conflicts. A common pattern: backend uses H3 for spatial aggregation and analytics, frontend uses ngeohash for lightweight geofence checks, and they exchange their respective cell IDs through the API. The two index systems run independently — no conversion between them needed.