A callable implementation of the Encoded Polyline Algorithm Format — the compact ASCII string that Google Directions/Routes returns as overview_polyline, that OSRM returns for geometries=polyline and polyline6, and that Valhalla returns as shape. Paste the string in, get coordinates, GeoJSON and a length out.
This is pure computation with no dataset: nothing is bundled, fetched or looked up. The format stores each coordinate as a delta from the previous one, scaled by 10^precision and rounded to an integer, zig-zag encoded so the sign lives in the low bit, split into 5-bit chunks least-significant first, and shifted into printable ASCII by adding 63. Every route here is deterministic and synchronous — the same query returns the same bytes forever, and no route reads the clock, a seed or a random number.
Precision 5 versus precision 6 — the one thing that goes wrong
| Provider |
Precision |
Google Directions / Routes overview_polyline, @mapbox/polyline default, GraphHopper |
5 |
OSRM geometries=polyline6, Valhalla shape |
6 |
Read a precision-6 string at precision 5 and the coordinates overflow ±90/±180 — /decode returns a 400 that names the exact latitude and point. Read a precision-5 string at precision 6 and you get a valid-looking answer that is ten times too small, which is why /decode and /simplify attach a meta.warnings entry when every coordinate lands inside latitude ±9 and longitude ±18. That warning is a heuristic with a disclosed false-positive mode (a genuine geometry near the Gulf of Guinea trips it), never a verdict. /inspect exists to answer the question properly.
Lossiness is structural
Encoding rounds to a fixed grid, so it is not lossless:
| Precision |
Latitude grid step |
Worst-case point displacement |
| 5 |
1.11195 m |
0.7863 m |
| 6 |
0.11120 m |
0.07863 m |
/encode reports, per point, the value you sent, the value the grid could store, and the great-circle offsetMeters between them, plus lossyPointCount, maxOffsetMeters and roundTripsExactly. The integer is Math.round(value * 10 ** precision); JavaScript breaks exact ties toward positive infinity, where C's round() (used by OSRM) breaks them away from zero — a difference only reachable with an exact …5 tie.
Putting a polyline in a URL
Exactly eight characters of the ASCII 63–126 alphabet are illegal raw in a query component under RFC 3986 section 3.4: [ \ ] ^ ` { | }. ~, _, ? and @ are legal and are never flagged. Note that all eight are inside the alphabet, so they decode perfectly — which is exactly why they have to be escaped rather than detected as errors. Anything below ASCII 63 — space, %, &, +, =, # — can never be part of a valid polyline, so if a route sees one the string was mangled in transit, and the 400 says which character at which index. /encode and /simplify hand back a ready urlEncoded form (encodeURIComponent, which also escapes @ and ? — over-escaping is safe, under-escaping is not), and /inspect lists disallowedInQueryCharacters.
Strictness: no partial decodes, ever
A mangled string is a precise 400 on /decode and /simplify, never a best-effort coordinate list. Three independent guards:
- accumulation is arithmetic (
chunk * 2 ** shift), never a 32-bit <</|=; JavaScript shifts are mod 32, so the textbook accumulator collapses to -1 on a string of continuation characters and returns a nonsense coordinate instead of failing;
- a single value spanning more than six characters is rejected — 30 bits is the most a WGS 84 delta can need, even at precision 6;
- every accumulated coordinate is range-checked before it can reach the response.
Guards 2 and 3 are genuinely independent: a well-formed six-character value can still be an out-of-range coordinate. Non-minimal encodings (a redundant trailing zero chunk) are accepted, reported via canonical: false and nonCanonicalGroupIndexes, and warned about — no real-world encoder emits them.
Limits, and why they are where they are
value and coordinates: 8,000 characters. Both caps are reachable, not decorative: 8,000 ? characters decode to 4,000 points, while a realistic 2,000-point trace at ~11 m spacing encodes to just over 8,000 characters.
/decode and /inspect: 2,000 points. Decoding is linear (1.08 ms for a 2,000-point request including serialization), so what binds here is response size. The reachable worst case is not a plausible trace but a deliberate one — 1,998 points that all render with six decimals and a three-digit longitude, from a value sitting exactly on the 8,000-character cap — because every coordinate is emitted twice, in coordinates and in geojson.coordinates, and meta.params echoes the whole value: 154,373 bytes, about 151 KiB compact. Adding ?pretty=true re-serializes that with two-space indent and reaches 302,472 bytes, about 295 KiB; the platform's 256 KiB budget is a compact-envelope figure and pretty is an opt-in debug flag, so this is stated rather than silently claimed to fit. (A 2,000-point staircase of short coordinates — the shape a benchmark naturally reaches for — is only 119 KiB, so do not take that for the worst case.)
/encode and /simplify: 1,000 points, plus one budget that is about CPU rather than bytes. /encode is linear. /simplify is not: Ramer–Douglas–Peucker degrades to O(n²) whenever the farthest point of every span sits beside one of its ends, and each point costs roughly twice as much again when the along-track projection falls off the segment and the distance has to be clamped to an endpoint. A zig-zag is the obvious shape that does this, but not the only one — a right-angle staircase that never reverses in either axis does it too, which is why the refusal below describes what the algorithm measured rather than claiming to know the shape you sent. A point cap alone cannot bound that honestly. Measured across five adversarial families (comb, near-pole decaying zig-zag, pole-crossing zig-zag, retrograde zig-zag, constant zig-zag) on the full route path including serialization: 3.0 ms at 500 points, 4.9 ms at 600, 10.9 ms at 1,000 — so a bare 1,000-point cap would leave no margin at all against the free-tier Worker's 10 ms of CPU per request. Raising toleranceMeters is not a way around that, though the reason is narrower than it might look: a span stops splitting as soon as its own measured deviation is within the tolerance, so a coarser tolerance genuinely does prune earlier — but on a constant-amplitude zig-zag every span deviates by the same amount, so the cost falls off a cliff instead of tapering. Measured on the 1,000-point zig-zag in the test suite (amplitude ≈ 22.218 m): toleranceMeters of 0, 1, 5, 10, 20, 22 and 22.21 all hit the budget, while 22.219 and above return at once — and return two points, because a tolerance at or above the deviation of the whole geometry from the straight line between its endpoints always collapses it to those endpoints. So the work is metered instead: one request may spend 150,000 spherical distance evaluations, which is enough that every geometry of up to 547 points always fits, whatever its shape (the absolute worst case for 547 points is 546 × 545 / 2 = 148,785 evaluations) and an ordinary trace is never affected — a realistic 1,000-point GPS trace needs a few thousand evaluations and 0.56 ms, a monotone 1,000-point staircase 0.80 ms. A geometry that pushes past the budget gets a precise 400 naming it, at about 4 ms, instead of being run over the platform's CPU limit; nothing is ever truncated or partially simplified. For a longer or denser geometry, use /decode, simplify it in slices, or ask your routing provider for a coarser overview first.
- All four routes are
compute, not list, deliberately: count caps at 100 and truncating a geometry to 100 points would be a silently wrong answer. The price is that format=ndjson and format=csv are unavailable on this endpoint. fields, exclude, pretty and unwrap all work.
Distances
lengthMeters, offsetMeters and maxDeviationMeters are great-circle values on the same mean-Earth sphere as /api/geodesy, from the same shared helper, with the IUGG mean radius 6,371,008.8 metres exposed as radiusMeters on every response. They are not road distance, routed distance, ellipsoidal (Vincenty/Karney) distance or terrain distance. Trigonometric results are rounded to millimetres (offsets to a tenth of a millimetre, since they are sub-metre by construction) — that is a cross-engine-stability measure, not a claim of millimetre accuracy.
/simplify
Ramer–Douglas–Peucker with spherical cross-track distance, clamped to the nearer endpoint when the along-track projection leaves the segment (the unclamped infinite-great-circle variant is a common library bug and keeps different points). It guarantees every dropped point sits within toleranceMeters of the chord that replaced it, and returns the measured maxDeviationMeters so you can check — that figure is rounded to the nearest millimetre, so if you set a tolerance to within half a millimetre of an actual deviation it can read up to 0.5 mm above the tolerance you asked for; the guarantee is on the geometry, not on the rounded report. It does not preserve topology — an aggressively simplified loop can self-intersect — it never snaps to roads, and it is not idempotent: re-simplifying the output at the same tolerance can legitimately remove more points, because the recursion then measures against the new chords.
What this endpoint deliberately does not do
No routing, map matching, road snapping or traffic. No ellipsoidal distance. 2D only — a stream carrying a third value per vertex surfaces as an odd value count, not as elevation support. Precision 5 and 6 only. This format only: HERE's Flexible Polyline and OSRM's geometries=geojson are different things. No MultiLineString or Polygon output — one encoded polyline is one line. No RFC 7946 section 5.2 antimeridian-aware bbox narrowing, so a geometry crossing the dateline yields a box spanning most of the globe, by design. GET only, hence the input caps; there is no POST route and no upload.
The four specification pages behind this endpoint were last read end to end on 2026-07-30.