Geofencing in Ruby without PostGIS
Where the idea came from. A research experiment: could you build a fast in-process geometry engine for point-in-polygon, geofencing, and…

Geofencing in Ruby without PostGIS
Where the idea came from. A research experiment: could you build a fast in-process geometry engine for point-in-polygon, geofencing, and radius queries — on vendored C, with no GEOS, no PostGIS, and no network hop per query?
Where the idea came from
I didn’t have a fire in production. I had a question.
Poking around Ruby C extensions, I caught myself wondering: could you answer a whole class of geometric questions — point-in-polygon, geofencing, radius checks — entirely in-process, without dragging in the two things people reach for by reflex? Namely a PostGIS extension on the database, and a system GEOS library.
The reflex answer to “is this point inside this zone” is always PostGIS. But the actual computation here is microseconds. Around it we wrap a database extension, a network roundtrip, and result parsing — and turn microseconds into milliseconds. rgeo, in turn, wants GEOS as a system library on its fast path, which means apt-get install libgeos-dev in every Dockerfile, every CI image, every developer laptop. I got curious: what if you removed both?
To keep the experiment honest I needed a yardstick — a representative workload to measure “good enough” against. I picked one as a thought experiment, on paper. Imagine a delivery service: a courier picks up an order, and the app answers a stream of small geometric questions on every GPS tick coming in over a WebSocket — which zone is this point in? is the courier inside the service area? how close to the dropoff? Thousands of active couriers, each sending coordinates every few seconds. No single question is hard; the difficulty is that there are a lot of them, and each one is a point-in-polygon test against a set of zones.
I never built that service. It was a yardstick — a hypothetical worst case to size the design against. The logic is simple: if a tool comfortably handles that imaginary firehose in-process, then the real, smaller jobs people actually have, it handles easily.
And it’s worth pausing on the architectural detail that makes the “just use PostGIS” reflex not so free:
The moment you route a high-frequency, latency-sensitive workload through your primary database, you create a shared resource that two very different traffic patterns now compete for: transactional business logic, and a geometric query on every coordinate update.
So idle curiosity turned into a concrete spec: in-process, no system libraries, no database extension, immutable, honestly planar — and fast enough that a query is a function call, not a network event. I closed the Gemfile and went to see what the ecosystem had.
What didn’t fit
rgeo — the de facto standard for geometry in Ruby, and a genuinely good library. But the fast predicates depend on GEOS via rgeo-geos, a system library you have to install and keep in sync across every environment. Its objects are also mutable and general-purpose — more than I needed, and harder to reason about under concurrency.
georuby — pure Ruby, no system dependencies, which is the right instinct. But pure Ruby in the inner loop of point-in-polygon, with no spatial index, is exactly where you don't want to be when you run the test millions of times.
PostGIS — the right tool for spatial joins, reprojection, and geodesy over millions of rows. The wrong shape for “answer this trivial geometric question a few thousand times a second, in-process, with no roundtrip.”
ffi-geos and friends — back to GEOS as a system dependency. Square one.
What I wanted didn’t exist as one package: in-process, no system libraries, no database extension, an immutable index safe to share across threads, honest that it’s planar geofencing and not a GIS — and fast enough that a query is a function call rather than a network event. That was the gap.
Principles I wrote down before any code
- Zero system dependencies. No GEOS, PostGIS, PROJ, GDAL. The C sources are vendored into the gem and compiled at install time.
gem installeither builds or fails at build — it never asks you toapt-getsomething first. - In-process, no roundtrip. A query is a function call against memory. No socket, no connection pool, no serialization on the hot path.
- Immutable and thread-safe by construction. The index is built once, frozen, and only ever read. Concurrent reads from normal Ruby threads need no locks because nothing mutates.
- Honest about being planar. This is not geodesy. It works in planar XY. Where it hands you meters, it says so right in the method name and documents the approximation.
- Exact memory accounting. Native memory living outside Ruby’s object slots is still reported to the GC, so
ObjectSpace.memsize_of(index)tells the truth. - One job, done well. Parsing, predicates, format conversion, a geofencing index, and local distance. Not buffers, not unions, not reprojection. A focused tool, not a GIS.
Quick look

That’s the whole shape: parse into a frozen geometry, build a frozen index, ask it questions.
To make the numbers mean something
The numbers below are from Ruby 3.1.7 on Apple Silicon. To make nanoseconds legible, keep a rough scale of magnitudes in mind: a CPU cache miss is tens of nanoseconds; a roundtrip to a database, even on localhost and before it does anything useful, is tens to hundreds of microseconds; a typical web action is tens of milliseconds. A single geofencing query here is on the order of 80 nanoseconds — two to three orders of magnitude below the network roundtrip alone, the one we set out to avoid.
Architecture in one paragraph
The gem is a Ruby C extension wrapping three vendored libraries: tidwall/tg for geometry parsing and predicates, tidwall/rtree.c for the spatial index, and tidwall/json.c for fast GeoJSON traversal. A TG::Geometry::Geom is a frozen Ruby wrapper around exactly one tg_geom pointer; it can't be mutated, manually freed, or hand-allocated. A TG::Geometry::Index is, physically, an exact-size C array of entries — { Ruby id, native geometry pointer, precomputed bbox, insertion ordinal } — with an optional R-tree of bounding boxes on top acting as a prefilter. A point query walks (or prefilters through the R-tree) the entries, marks matching ordinals in plain C memory inside the search callback, and only after the search returns does it materialize a Ruby result under the GVL. Nothing on the query path parses text, allocates geometry, or mutates the index.
Technical findings
1. Vendoring C beats depending on a system GEOS
The single most important decision was to vendor tg, rtree.c, and json.c straight into the gem and compile them with the extension, rather than link against a system GEOS.
The naive view is “don’t reinvent the wheel, link the system library.” But a system library is a deployment liability. Every environment — laptop, CI, staging, the production image, the new hire who joined yesterday — needs the right version installed before bundle install will even work. And it fails with a cryptic linker error, far from the line of code that caused it.
Vendored sources flip that: the build is hermetic. No libgeos-dev, no version skew, no "works on my machine." gem install tg_geometry either compiles a self-contained extension or fails loudly at build. For a library whose whole pitch is "drop it in and go," that's worth more than the convenience of reusing a system binary.
2. The R-tree is a prefilter, not the answer — and here’s where it earns its keep
Add a spatial index and you’re tempted to cut the corner and treat a bounding-box hit as a match. It isn’t: a point can sit inside a zone’s rectangle and be well outside the polygon itself. So the R-tree does exactly one thing — cheaply discard the zones whose bboxes can’t possibly contain the point, leaving two or three candidates — and the exact predicate on the real geometry decides.
What that buys in numbers. Two index strategies on “find the covering zone” (find_covering) over 5,000 zones:

query flat (full scan) rtree (prefilter) miss — covers nobody ~4,900 ns ~69 ns point deep in the set ~600 ns ~210 ns point hits the first zone ~81 ns ~95 ns
The R-tree holds ~70–95 nanoseconds almost regardless of index size, while the full scan degrades linearly on a miss — up to ~4.9 microseconds at 5,000 zones. That’s roughly a 70× gap on the query that’s worst for a linear scan.
And the detail that makes the picture real: in that last row, flat is faster than the R-tree. If the point hits the very first zone in insertion order, the full scan returns on the first match while the tree pays for traversal. A fast index isn’t “always faster” — it’s faster exactly where the scanner has to scan. That’s why there’s no strategy: :auto baked in: pick the strategy for your data and measure.
To ground those ~80 ns: that’s less than a single cache miss, and two to three orders of magnitude below a database roundtrip. The case where a query really is a function call, not a network event.
3. Immutability is the concurrency strategy
Falcon forks, Puma threads — either way the index is read from many places at once. The usual answer to shared mutable state is locks. The better answer is to remove the mutability.
The index is frozen after build. Entry pointers are stable, the entries array is never reallocated, there's no per-query state on the index, no match cache — nothing to protect. Concurrent readers can't step on each other because there's nothing to step on. Four threads hammering find_covering against one frozen index push ~6.3M queries/s combined — no locks, essentially no allocations. Under MRI's GVL that's not a 4× parallel speedup; the value is elsewhere — many readers safely sharing one index with not a single lock.
Updating the data doesn’t break this. You don’t mutate the index — you build a new one and swap the reference:

4. Safe memory consumption: teaching the GC about what it can’t see
This is the part I spent the most time on, and the part that’s completely invisible from the outside — but it’s exactly what decides whether you can keep the gem in a long-lived process for years.
A tg_geom parsed from a big MultiPolygon can be hundreds of kilobytes of native memory — but to Ruby's GC the wrapper object is tiny. The GC sees a small slot and has no idea there's a quarter-megabyte of polygon hanging off it. Leave it alone and the GC under-counts pressure and fires too late, while ObjectSpace.memsize_of lies.
The fix is to report native bytes to the GC explicitly via rb_gc_adjust_memory_usage, and to account for them precisely. And it's measurable: ObjectSpace.memsize_of(index) actually sees the entries, the owned geometries, and the exact R-tree bytes.

That’s about 256 bytes per zone for flat, plus another ~75–105 bytes per zone for the R-tree — and all of it shows up in the accounting instead of hiding in the native heap.
But the real test isn’t a one-shot snapshot, it’s behavior under load over time. I ran a stress pass: 1,000 zones, 100 full index rebuilds (alternating flat and rtree), 11.17 million queries back to back, a steady 2.3M queries/s.

RSS climbs gently from ~39 to ~46 MB and plateaus — no runaway growth across a hundred build-and-discard cycles. The invariant I held in C: every +N in the accounting has exactly one matching −N on the free path, and dispose is idempotent. The ~6.7 MB of drift is allocator retention, not a leak — the curve flattens and stops climbing.
Underneath this are two ownership models, and the distinction is the whole game. With via: :geojson/via: :wkb the index owns the geometry: it parses it, frees it on dispose, counts its bytes. With via: :geom the index borrows: it stores the pointer plus a reference to the owning Ruby wrapper, marks that wrapper for the GC (including under compaction), and never frees the borrowed pointer — bytes counted once, on the owner, no double-counting.
5. Planar, not geodesic — and saying so out loud
It would’ve been easy to expose a distance method and let everyone assume it returns real meters. That would be a lie — the gem works in planar XY.
So units live in the method names. distance_to_xy returns input coordinate units, full stop. distance_to_lnglat_meters returns approximate meters via a local equirectangular frame anchored at the query latitude — explicitly documented as geofencing-grade, not geodesy. The distance itself is cheap: ~340 ns on a 16-vertex polygon, and _lnglat_meters costs about the same as _xy. The planar metric also doesn't wrap longitude at ±180 — data crossing the antimeridian must be cut before import, and the docs say exactly that. An approximation you can see is safe; an approximation hidden behind an honest-sounding name is a trap.
6. Bulk import: why I bothered with the GVL
Here I want to name the reason for the complexity outright — otherwise it looks like over-engineering, and it isn’t.
A short primer on the target runtime. Falcon scales by processes (forks), but inside one process concurrency is cooperative: each client is essentially its own fiber on a single reactor, and fibers only yield at await points (I/O). Until a fiber yields, the others wait. Hence the trap: a long synchronous call into C yields to no one. If parsing a big GeoJSON goes into C and holds the thread, it freezes not one client but the entire reactor — every fiber in that process waits while the file parses.
I didn’t go read how JSON.parse behaves in recent Ruby. But writing my own GeoJSON parser, I wanted to be 100% sure that a big file wouldn't stall a process with live clients. So FeatureSource's heavy phase — file read, validation, traversal via json.c, parsing each geometry — is designed around that. It touches only C memory: no Ruby objects, methods, exceptions, or GC calls during that phase. For the duration it releases the GVL (which lets other Ruby threads run), and on Rubies with the offload-safe no-GVL API (RB_NOGVL_OFFLOAD_SAFE) the phase is marked offload-safe — meaning the heavy work can be moved aside so the reactor itself keeps serving the other fibers. Ruby ids and the transfer of geometry ownership into the index happen only after the GVL is back. This isn't ornamentation — it's a direct answer to how cooperative concurrency works in the target runtime.
And as a bonus it’s simply faster and lighter on memory than the usual path — JSON.parse the whole file and walk the resulting tree of Hashes. On a FeatureCollection of 1,000 zones:

That’s ~3.6× faster and ~7× fewer allocations, and loading the file straight into the index, skipping the intermediate Ruby strings, gets you ~22× fewer allocations. At 10,000 zones the ratios hold. The win comes from exactly where JSON.parse builds an enormous tree of objects only for you to throw it away immediately.
7. Packed point batches — a modest but real bonus
When you have many points, you can hand the index packed native-endian doubles (points.pack("d*")) instead of an array of arrays and get answers for all of them in one call. That cuts the per-point Ruby method dispatch and object churn, and buys ~1.5–1.8× over a plain find_covering loop on rtree. Not magic, but steady and with no GC pressure.
Wiring it to a frontend: a geofence over a WebSocket
To make the “a query is a function call” claim tangible, here’s the end-to-end with that same courier firehose (which, again, was hypothetical). Transport is Falcon + WebSocket; all the geometry lives in the process.
The frontend (the courier’s app) sends its coordinate every couple of seconds:

The backend makes exactly two geometric calls per message — both against memory, no DB, no network:

And hands the frontend a ready answer — zone, distance, and an arrival flag:

The point: find_covering here is those ~80 ns, and distance_to_lnglat_meters is ~340 ns. Two function calls per message, no roundtrip to a database. So a single process absorbs the whole fleet's tick rate — and when coordinates arrive in bulk (a batch from several devices), one covering_ids_batch_packed runs them all at once.
Gotchas I hit the hard way
You cannot allocate Ruby memory or raise inside an R-tree callback. rtree.c calls back into your allocator and your search visitor. If the allocator calls ruby_xmalloc, or the callback raises a Ruby exception, you longjmp straight out of the middle of the C library's stack and leave its state corrupted. The allocator here uses plain malloc and returns NULL on OOM; the callbacks touch only C memory. Ruby work happens strictly before and after.
Attributing R-tree memory to the right index needs a thread-local owner. The rtree.c callback signature carries no context about which index an allocation belongs to. The answer is a _Thread_local "current owner" pointer, set before the build and restored with rb_ensure so an exception mid-build can't leave it dangling.
Borrowed geometry and GC.compact is where lifetimes go to die. A via: :geom index holds a raw native pointer whose memory is owned by a Ruby wrapper. Fail to mark that wrapper as a reachable, movable reference and the collector frees it — or compaction relocates it — while the index still points at the old address. It passes every test that doesn't trigger a compacting GC at the wrong moment, then fails in production.
SRID is metadata, and pretending otherwise would be worse. parse_wkb/parse_hex preserve the EWKB SRID, to_wkb writes plain WKB, to_ewkb writes it back. But the gem never checks SRID compatibility or reprojects — because silently reprojecting coordinates is exactly the kind of "helpful" behavior that produces wrong answers nobody notices.
What it isn’t
Not a GIS. No geodesic/Haversine distance, no projection/reprojection, no buffers, unions, differences, or convex hulls. Need those — you need GEOS or PostGIS, and that’s fine.
No KNN. There’s radius filtering (bbox prefilter + exact distance), but no nearest_ids.
No serialization or mmap. The index is a live in-memory structure, not a file format. You rebuild it on boot.
Planar only. It works in XY; lng/lat is planar degrees with the documented local-meters approximation.
No Ractor, no Windows, no JRuby in this release. Read-only access from normal threads is the supported model. The AR type is read-only.
A few words about PostGIS
I’ve leaned on PostGIS as the foil throughout, so to be fair: it’s one of the great pieces of open-source infrastructure. Geodesy, reprojection across thousands of coordinate systems, spatial joins over millions of rows, indexing hardened over two decades. Need any of that and PostGIS is the answer, and nothing here competes with it.
The point was never that PostGIS is wrong. The point is the shape of the problem. Recall the scale: a query here is ~80 ns, while the database roundtrip alone is tens to hundreds of microseconds — two to three orders of magnitude more, before the database computes anything. For a geofence of a few thousand zones, queried millions of times a second from inside the app, with no need for reprojection or geodesy, that difference is the whole question. That workload wants a frozen array in memory and a function call, not a database extension and a network hop. Different shape, different tool.
It’s the same lesson I keep relearning: match the tool to the runtime and the workload, not to the default.
Closing
I wrote this gem not because something was on fire, but because I wanted to test a hypothesis: could you cover a whole class of geometric problems entirely in-process, without PostGIS, without a system GEOS, without a network hop? The courier firehose was a thought-experiment yardstick; that service never existed. But while running the idea against that imaginary worst case, the genuinely interesting part of the work surfaced — getting native memory management right from inside an extension, and proving it doesn’t leak under load.
Every decision is a response to something concrete. Vendored C — because apt-get install libgeos-dev doesn't belong in a Bundler workflow. The R-tree as a prefilter with insertion-ordered results — because a fast index that loses your priority semantics isn't faster, it's broken. Immutability — because the cheapest lock is the one you never need. A no-GVL parse phase — because one big file shouldn't freeze a Falcon reactor full of live clients. Explicit GC accounting and a flat RSS curve over 11 million queries — because native memory the collector can't see is native memory that leaks in the dark. Method names that carry their units — because the most dangerous distance function is the one that looks like meters and isn't.
What came out isn’t a GIS and isn’t a PostGIS replacement — it’s a focused tool for a narrow but real class of jobs. If it closes one of those for someone without standing up extra infrastructure, the experiment paid for itself.
🔗 github.com/roman-haidarov/tg_geometry
🔗 rubygems.org/gems/tg_geometry
Ruby · Geometry · Geofencing · C Extension · PostGIS
메타데이터
- post_id
- 801e126f8c32
- slug
- geofencing-in-ruby-without-postgis-801e126f8c32
- url
- https://medium.com/@romnhajdarov/geofencing-in-ruby-without-postgis-801e126f8c32
- canonical_url
- https://medium.com/@romnhajdarov/geofencing-in-ruby-without-postgis-801e126f8c32
- author_url
- https://medium.com/@romnhajdarov
- status
- ok
- fetched_at
- 2026-06-16 19:09:56