Turf.js on the server, in the browser, or in a worker: three places one library lives
title: "Turf.js on the server, in the browser, or in a worker: three places one library lives" slug: turf-server-browser-worker pillar: Engineering Quality angle: trade-off audience: CTOs, senior engineers, fellow practitioners sourceRepo: atkins-web stack: Next.js targetWords: 2200 status: draft
Turf.js on the server, in the browser, or in a worker: three places one library lives
Intersect, buffer, centroid — every Turf op is cheap on one feature and brutal on ten thousand. The rule for picking which side of the wire the spatial work happens on.
The decision and why it cannot be deferred
Turf.js is one of those libraries that hides a load-bearing architectural choice behind a friendly import. import * as turf from '@turf/turf' works the same in a Next.js API route, in a React component, and inside a Web Worker. The function signatures do not change. The bundle implications, the network cost, and who pays for the CPU all do.
In a recent Next.js 15 viewer project I worked on, every Turf call sits in the browser. Three files, three different reasons to call into the library, zero server-side spatial computation. That was a deliberate decision, but it was not the only defensible one. Looking back at the call sites makes the trade-off concrete, so I want to walk through what each location costs and what makes one win for a given operation.
The relevant fact: @turf/turf@^7.2.0 is in package.json, and the only invocations I can find in the repo are these three.
$ grep -rn "turf\." app components --include="*.ts" --include="*.tsx"
components/viewer/components/LayerInfoComponent.ts:44: const clickedPoint = turf.point([e.lngLat.lng, e.lngLat.lat]);
components/viewer/components/LayerInfoComponent.ts:60: return turf.booleanPointInPolygon(clickedPoint, feature as any);
components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/index.tsx:434: return turf.booleanIntersects(feature.geometry, polygon_intersection);
components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/partials/InfoBox/index.tsx:67: const center = turf.centerOfMass(feature as any)
components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/partials/InfoBox/index.tsx:80: const distance = turf.distance(clickedPoint as any, featurePoint as any, { units: 'meters' })
Three browser components. The app/api/* routes that handle GeoJSON (app/api/upload/geojson/route.ts, app/api/geojson/city-averages/route.ts) do parsing, validation, coordinate conversion, and stats — none of them call Turf. That asymmetry is the post: when does Turf belong on the server, when in the browser, when in a worker, and why this repo settled where it did.
Option A — Turf in the browser, next to the map
This is where the repo lives today. The reason is simple: every Turf call here is driven by a mouse event, and the source-of-truth geometry is already in browser memory because Maplibre has rendered it. Round-tripping to the server would mean serializing thousands of features into a POST body, paying TLS handshake plus middleware overhead, and waiting for a response — to answer a question the browser can answer in under a frame.
The cleanest example is LayerInfoComponent.ts. When the user clicks the map, we wrap the lng/lat into a Turf point and ask, for each rendered feature in the active source, whether the click landed inside its polygon.
// components/viewer/components/LayerInfoComponent.ts
private handleClick(e: maplibregl.MapMouseEvent) {
if (!this.map) return;
const clickedPoint = turf.point([e.lngLat.lng, e.lngLat.lat]);
const sourceId = this.layer.source as string;
const sourceLayer = (this.layer as any)["source-layer"] || "";
const features = sourceLayer
? this.map.querySourceFeatures(sourceId, { sourceLayer })
: this.map.querySourceFeatures(sourceId);
const intersecting = features.filter((f) => {
if (!f.geometry) return false;
const feature = { type: "Feature", geometry: f.geometry as GeoJSON.Geometry, properties: f.properties };
try {
return turf.booleanPointInPolygon(clickedPoint, feature as any);
} catch {
return false;
}
});
// ...show popup
}
querySourceFeatures hands back the geometry the browser already has. booleanPointInPolygon is point-in-polygon — a few divisions and comparisons per ring vertex. For a layer like the district file in public/data/District_05092025.geojson (~5.2 MB, dozens of polygons rendered in view) the whole loop runs inside the click handler with no visible delay. There is no version of this that should be server-side; the latency budget for "show a tooltip on click" is one frame.
The buffer-and-distance variant is InfoBox/index.tsx. The user clicks, we treat the click as a 500 m search radius, and for every feature on the active source we compute a representative point and ask how far it is.
// components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/partials/InfoBox/index.tsx
const clickedPoint = [e.lngLat.lng, e.lngLat.lat]
const radius = 500
function getRepresentativeCoord (feature: any): [number, number] | null {
const geom = feature?.geometry
if (!geom) return null
try {
switch (geom.type) {
case 'Point': return geom.coordinates as [number, number]
case 'MultiPoint':
case 'LineString': return geom.coordinates?.[0] as [number, number]
case 'MultiLineString':
case 'Polygon': return geom.coordinates?.[0]?.[0] as [number, number]
case 'MultiPolygon':return geom.coordinates?.[0]?.[0]?.[0] as [number, number]
default: {
const center = turf.centerOfMass(feature as any)
return center?.geometry?.coordinates as [number, number]
}
}
} catch {
return null
}
}
function isWithinRadius (feature: any) {
const featurePoint = getRepresentativeCoord(feature)
if (!featurePoint || typeof featurePoint[0] !== 'number') return false
const distance = turf.distance(clickedPoint as any, featurePoint as any, { units: 'meters' })
return distance <= radius
}
Two things to notice. First, the function avoids turf.centerOfMass whenever the geometry has an obvious anchor coordinate — it falls through to the Turf call only for shapes the switch does not handle. That is a quiet optimization that matters more than it looks: centerOfMass allocates and runs a polygon integration, while indexing into a coordinate array is free. Second, turf.distance is a haversine call. For 500 m radii at city latitudes the great-circle math is fine; the alternative — projecting both points into a metric CRS first — buys nothing here.
What you pay for keeping this in the browser:
- Bundle weight. The umbrella
@turf/turfimport is large. The repo accepts that because the viewer is already a heavy SPA (maplibre-gl,@react-three/fiber,three,pdfjs-dist,tinymceare all inpackage.json). On a leaner page the same code should switch to per-function imports like@turf/boolean-point-in-polygon. - Battery. Distance/intersection across a few thousand features on a mid-tier phone is fine. Across tens of thousands, the click handler will stall the main thread long enough to drop frames on the next pan. That is the cliff that pushes you to Option C.
- Determinism. Two users on two devices running different Turf versions can in theory produce different results for edge cases (degenerate rings, antimeridian crossings). In practice for interactive UI this is acceptable; for billing or compliance work it is not.
Option B — Turf on the server, computed once, sent down as data
When the spatial operation has to be the same for everyone, or when the inputs do not live in the browser yet, the call belongs in a Next.js route handler. The repo does not do this for Turf specifically, but it does the equivalent for a non-Turf summary in app/api/geojson/city-averages/route.ts:
// app/api/geojson/city-averages/route.ts
import { NextResponse } from 'next/server'
import path from 'path'
import { promises as fs } from 'fs'
import { calculateCityAverages } from '@/components/viewer/components/SpiderChart/utils'
export async function GET() {
try {
const jsonDirectory = path.join(process.cwd(), 'public', 'data')
const fileContents = await fs.readFile(jsonDirectory + '/District_05092025.geojson', 'utf8')
const geojsonData = JSON.parse(fileContents)
const districts = geojsonData.features.map((feature: any) => feature.properties)
const cityAverages = calculateCityAverages(districts)
return NextResponse.json({
success: true,
data: cityAverages,
totalDistricts: districts.length
})
} catch (error) {
console.error('Error calculating city averages:', error)
return NextResponse.json({ success: false, error: 'Failed to calculate city averages' }, { status: 500 })
}
}
That route reads a 5 MB GeoJSON, computes an aggregate over features[].properties, and returns a small JSON blob. The shape — heavy input on disk, tiny output to the wire — is exactly when server-side Turf makes sense. If you swap the property aggregation for turf.intersect, turf.union, or turf.area over the same dataset, the calculus is identical: do not pay to ship 5 MB to the browser only to reduce it to a number.
Concretely, server-side Turf wins when:
- The input is on disk or in the database, not in browser memory.
- The output is much smaller than the input — counts, areas, a flattened polygon.
- The result is the same for every user, so it can be cached.
- You need determinism (one Turf version, one Node runtime).
What you pay:
- Latency. A POST round trip plus serialization. For datasets where the response is small this is dominated by RTT and is acceptable; for "live recompute on slider drag" it is not.
- Server cost. Turf calls allocate.
intersecton two complex polygons can hold tens of megabytes for the lifetime of the call. On serverless that translates directly to billed compute and memory. - Cold starts. The umbrella
@turf/turfimport inflates cold-start times on Vercel-style platforms. Per-function imports (@turf/intersectalone) help.
The upload route in the same repo is a good place to think about a future Turf call. Today it only validates the GeoJSON and converts CRS:
// app/api/upload/geojson/route.ts (excerpt)
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File;
// ...size and extension guards
const content = await file.text();
const parsedData = JSON.parse(content);
const validation = validateGeoJSON(content, file.name, file.size);
if (!validation.isValid) {
return NextResponse.json({ success: false, error: 'Invalid GeoJSON format', details: validation.errors }, { status: 400 });
}
const detectedCRS = detectCoordinateSystem(parsedData);
// ...convert to EPSG:4326 if needed, return layer config
}
The day a feature like "auto-snap uploaded polygons to the nearest district" lands, that call belongs here — on the server, in the upload pipeline, run once. It would be a mistake to ship the uploaded polygon back down, recompute snap-to-district in every viewer, and have three browsers disagree. The fact that this route already owns the file and the CRS conversion makes it the right place for the heavy spatial step too.
Option C — Turf in a Web Worker, for the case that fits neither
The third location matters when the operation is big enough to jank the main thread but small enough that round-tripping to a server is wasteful, or when the input is already in the browser (drawn polygon, uploaded file, current viewport) and shipping it up just to ship the answer back down is the wrong shape.
The repo does not have a worker today. It has the call site that would be the obvious candidate, in LayersPanel/index.tsx:
// components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/index.tsx
const getHexIntersection = (polygon_intersection: any) => {
try {
const map = globalMap as any
let features: any[] = []
try {
if (map?.getLayer?.('3level-thematic')) {
features = map.queryRenderedFeatures({ layers: ['3level-thematic'] }) as any[]
} else {
features = []
}
} catch { features = [] }
// intersected features
const intersectedFeatures = features.filter((feature: any) => {
return turf.booleanIntersects(feature.geometry, polygon_intersection);
});
intersectedFeatures.unshift(polygon_intersection);
map.addSource(intersectedLayerId, { type: 'geojson', data: { type: 'FeatureCollection', features: intersectedFeatures, properties: {} } } as any)
map.addLayer({ id: intersectedLayerId, type: 'fill', source: intersectedLayerId, paint: {'fill-color': ['case',['has', 'color'],['get', 'color'], '#088'],'fill-opacity': ['case',['has', 'color'],0.1,0.6]} } as any)
} catch (err) {
console.log('Error in : Intersection', err);
return false
}
}
The user draws a polygon, this function takes the rendered hex layer (3level-thematic), and asks Turf which hexes intersect the drawn shape. On a sparse hex grid this returns instantly. On a dense 3level grid covering a city, the synchronous filter loop is the kind of work that drops frames — and crucially, it runs on the main thread that also has to keep the map pan/zoom interactive.
The worker-shaped version of this is straightforward: ship polygon_intersection and the array of feature geometries (as a transferable ArrayBuffer of flat coordinates, ideally) to a worker, run turf.booleanIntersects there, post back the matching IDs, then on the main thread look those IDs up and call addSource / addLayer. The UI stays responsive because the heavy loop is off the main thread. You pay a one-time worker boot, a structured-clone or transfer to send geometry, and the engineering cost of having two code paths for what used to be one synchronous function.
The signal for "promote to worker" is concrete: when the synchronous Turf call holds the main thread longer than ~16 ms during an interactive gesture. Below that it does not matter. Above that, frame drops start showing up in DevTools' Performance tab, and users notice on the next pan even if they cannot point at what went wrong.
What you pay for putting Turf in a worker:
- Two implementations. A function call becomes a message protocol. Errors cross the boundary as serialized strings.
- Transfer cost. GeoJSON is verbose. Cloning a 5 MB feature collection to a worker takes real milliseconds. Transferable typed arrays help, but the GeoJSON object model does not give them to you for free — you have to flatten coordinates yourself.
- Bundle duplication. Webpack/Turbopack will produce a separate chunk for the worker, and that chunk includes its own copy of any Turf functions you import. Use per-function imports here even if the main bundle uses the umbrella.
The deciding factor in this repo and the artifact that justifies it
The reason every Turf call in this codebase is in the browser is the call shape, not laziness. Every one of them is event-driven and reads geometry that the map renderer already owns. Look at the three sites:
LayerInfoComponent.tscallsturf.booleanPointInPolygonon click.InfoBox/index.tsxcallsturf.centerOfMassandturf.distanceon click.LayersPanel/index.tsxcallsturf.booleanIntersectsafter the user finishes drawing a polygon.
There is no third party that needs the same answer. There is no value in caching the result — the next click answers a different question. The latency budget is one user interaction, and the input is already in V8 memory because Maplibre put it there. Server-side Turf in this shape would mean serializing thousands of features into a POST body, paying TLS, paying middleware, paying cold start, all to ask "is point X inside polygon Y." That is a worse trade for every user and the same answer for none.
The recent commits in the repo back this up:
c263478 fix: preventing removing analysis layers
f8ecb55 feat: layer scrolls
14e48c9 feat: multi layer analysis colors
ba6e0b8 feat: multi layer analysis
787d217 Add separate taz and hex layers
"Multi layer analysis" and "separate taz and hex layers" are exactly the kind of work where Option C (worker) starts to win. When you go from one rendered analysis layer to three or four, the synchronous intersect in getHexIntersection runs three or four times in a row on the same click — and the main thread is the same main thread. The next refactor on that file is the worker promotion, not a move to the server.
CTA — the question that flips the decision for a reader with different constraints
If your stack looks similar — a Next.js app, Maplibre or Mapbox in the browser, Turf on top — the question that flips this decision for you is: who needs the result, and where does the input already live?
- One user, transient answer, input in browser memory → Option A (browser).
- Many users, same answer, input on disk or in DB → Option B (server).
- One user, input in browser memory, but the synchronous call holds the main thread > 16 ms → Option C (worker).
The order matters. Default to Option A; promote to B only when the answer is shared; promote to C only when the profiler shows you frames being dropped, not because workers feel architecturally tidy.
Trade-off
The recommendation accepts three things. First, that browser Turf will eventually hit the wall on someone's mid-range Android phone and you will have to write the worker version anyway — the question is whether you write it before or after the bug report. Second, that the umbrella import * as turf from '@turf/turf' is convenient and wrong: when the app starts feeling slow, the first fix is per-function imports (@turf/boolean-point-in-polygon, @turf/distance, @turf/intersect), not architectural surgery. Third, that the server-Turf option implies a real platform discipline: pinning a single Turf version, agreeing on a single CRS, and treating spatial computation as a versioned API rather than a helper.
Business impact
Picking the wrong location for a Turf call rarely shows up as a bug. It shows up as a "the dashboard feels slow when there are a lot of layers" comment from a stakeholder, or a serverless bill that creeps up because a function allocates 200 MB to intersect two polygons it could have done client-side. The cost of getting this right is one architectural review at the moment a feature lands; the cost of getting it wrong is paid every interaction, by every user, on every device, for as long as the feature lives. For an interactive geospatial product that ratio is the whole UX budget.
What to do next
Grep your own repo: grep -rn "@turf\|turf\." --include="*.ts" --include="*.tsx". For each call site, write down two facts: what triggers it (event, route, scheduled job) and where the input geometry lives at that moment (browser memory, request body, disk, database). If trigger and input live in the same place, you have the right location. If they do not, you have a refactor — usually a small one, sometimes a worker.
{
"title": "Turf.js: server, browser, or Web Worker — picking one",
"metaDescription": "When to run Turf.js on the server, in the browser, or in a Web Worker — concrete trade-offs from a Next.js 15 viewer using @turf/turf 7.",
"slug": "turf-server-browser-worker",
"canonical": null,
"primaryKeyword": "turf.js server vs client",
"secondaryKeywords": [
"turf.js web worker",
"turf.js performance",
"next.js geospatial",
"geojson server processing",
"booleanPointInPolygon",
"booleanIntersects",
"maplibre turf"
],
"audience": "CTOs, senior engineers, fellow practitioners",
"searchIntent": "comparison",
"internalLinkTargets": ["/services", "/case-studies"],
"schema": {
"type": "BlogPosting",
"faq": [
{
"q": "When should Turf.js run on the server instead of the browser?",
"a": "When the input geometry already lives on the server (disk, database, uploaded file) and the answer is the same for every user. Server-side Turf wins on shared, cacheable results with small outputs; the browser wins on per-user interactive answers."
},
{
"q": "When is a Web Worker worth it for Turf.js?",
"a": "When a synchronous Turf call holds the main thread longer than one frame (~16 ms) during an interactive gesture. Below that the worker overhead is not worth the engineering cost; above that, the main thread blocks pan/zoom and users feel it."
},
{
"q": "Does importing the full @turf/turf package matter for bundle size?",
"a": "Yes. The umbrella import is convenient but heavy. When bundle weight or cold-start matters, switch to per-function imports (@turf/boolean-point-in-polygon, @turf/distance, @turf/intersect) — same APIs, much smaller bundles."
},
{
"q": "How do you keep server-side Turf results deterministic?",
"a": "Pin a single Turf version, agree on a single CRS (usually EPSG:4326 for storage, with explicit projection for metric ops), and treat the spatial computation as a versioned API. Two clients on two Turf versions can disagree on edge cases."
}
]
},
"citation": {
"rule": "SEO_and_AEO_Rules/blog-seo-checklist.md",
"cluster": "SEO_and_AEO_Rules/search-intent-and-topic-clusters.md"
},
"coverImagePrompt": "Three labeled abstract regions on a dark navy canvas: left a stylized server-rack box of horizontal slabs, center a browser window frame with a thin pinned tab bar, right a circular loading spinner representing a worker thread. A single continuous polygon shape (light teal outline) passes through all three regions left to right, with small dotted connector lines between them. Flat geometric shapes, no text, no logos, minimal palette: dark navy background, light teal accent, pale gold highlight on the shape edges."
}
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox