A campaign takes on 200 places a day, and widening its search area costs no AI at all A developer detailed how Nakodo's paid campaigns onboard businesses from the open Overture Maps places dataset at a capped rate of 200 per rolling 24-hour window, using Postgres bounding-box prefiltering plus exact great-circle distance in TypeScript instead of PostGIS. The writeup explains the overfetch factor of four to offset the roughly 21% of bounding-box hits that fall in circle corners, and a DeferJob mechanism that waits on missing map-cell imports bounded by job age rather than deferral count. On Nakodo's paid plans a campaign can look for businesses instead of creators: cafes, gyms, shops, salons, in places you pick. The businesses come out of Overture Maps https://overturemaps.org/ , an open monthly places release, imported into Postgres half a degree of latitude and longitude at a time. I wrote about reading those files with DuckDB https://dev.to/daniel pertu/a-website-shared-by-more-than-three-places-is-a-chain-and-163-of-the-318-we-flagged-had-no-brand-4nlp and about the chain rule that falls out of them. This post is about the next problem, which is that the listing is far too big to be useful as-is. A 25 km radius around a mid-sized English city has tens of thousands of places matching "cafe" alone. Importing them is cheap. Doing anything with them is not: each new business costs a website read, a robots check and an AI fit judgement. js const PLACES PER DAY = 200; // new businesses a campaign takes on each day, nearest first The number is not a performance limit. It is the rate at which a customer can actually consume businesses. Our paid plans send 25 or 75 first emails a weekday, so a campaign that admitted 10,000 businesses on day one would have paid for 10,000 site reads and fit judgements to use a few hundred of them, and the brand would be looking at a list it cannot read. The count is deliberately not a calendar day: const { today } = await db .select { today: count } .from campaignChannels .where and eq campaignChannels.campaignId, campaignId , like campaignChannels.channelId, "web:%" , gt campaignChannels.createdAt, new Date Date.now - DAY MS , ; if today < PLACES PER DAY await addPlaces ctx, campaignId, campaign.targeting, PLACES PER DAY - today ; A rolling 24 hour window, so a campaign launched at 23:50 does not get 400 businesses in ten minutes, and a run that was interrupted tops up the remainder on its next pass rather than waiting for midnight somewhere. The like "web:%" matters too: the same campaign can also find businesses as Instagram and TikTok accounts, and those are not drawn from the listing, so they do not spend this allowance. A campaign's places are circles with a radius from 2 to 200 km, or whole countries. Postgres does the cheap part with a bounding box per place, longitude corrected for latitude: js const dLat = l.radiusKm / KM PER DEGREE; const dLng = l.radiusKm / KM PER DEGREE Math.max 0.05, Math.cos l.lat Math.PI / 180 ; then orders by the smallest corrected squared degree distance to any of the campaign's points, fetches limit 4 , and the exact work happens in TypeScript: real great-circle distance to every place, keep the ones genuinely inside a radius, sort, take the limit. No PostGIS, which was a deliberate choice made when the only geometry we needed was "how far is this from that". The overfetch factor is the interesting bit of arithmetic. A circle inscribed in its bounding box has an area ratio of π/4, so about 21% of box hits are corner junk, and the independents filter can drop more. Fetching four times the limit means the corners almost never starve the batch, and eight hundred rows of a dozen columns is nothing. When a campaign has no points at all, only whole countries, there is no distance to sort by, so the ordering falls back to the listing's own confidence. A campaign's area may not be imported yet. requestImports asks Trigger.dev for the missing cells and returns how many areas are still outstanding, and then: if pending 0 && Date.now - job.createdAt.getTime < IMPORT WAIT MS { throw new DeferJob new Date Date.now + 2 60 000 ; } DeferJob is our queue's "not now, try at this time" signal: it does not count as a failure and does not burn an attempt. The condition is the thing I would get wrong if I wrote it again from scratch. The bound is on the age of the job, not on the number of deferrals, so a search started six hours ago stops waiting no matter how many times it has looked, and the daily maintenance pass queues a fresh one. Bounding by attempts would have meant a stalled import turning into an infinitely patient job, and bounding by wall clock inside the handler would have meant holding a worker slot for six hours. The import request itself is idempotent, keyed by a SHA-1 of the run payload with a two hour TTL, so a job that defers 180 times does not trigger 180 imports. Every business that is out of scope is set aside with a sentence rather than deleted: "Outside your places", "No website or email listed", "Contact form only, no email". The customer sees the reason, which was the original motivation. The second benefit showed up when someone widened a radius from 10 km to 50 km. Changing a campaign's places re-runs the scoring job, and that pass is explicitly free of AI and API calls: js const near = nearest { lat, lng, countryCode }, campaign.targeting.locations ; if near.inside { / set aside as OUTSIDE / continue; } // Back inside after the places grew: carry on from where it stopped. const stage = cc.stage == "rejected" ? cc.stage : cc.scoreBreakdown ? "scored" : "discovered"; if cc.stage === "rejected" && stage === "discovered" await enqueue enrichJob cc.channelId ; A business score has two parts, and how it works https://nakodo.app/how-it-works businesses publishes the weights: 85% how well it fits the brief, judged by AI, and 15% how close it is. Widening the radius changes only the second part, and the first part is already stored in scoreBreakdown . So the rescore reads the old relevance back out and recomputes the total with the new distance: const { score, breakdown } = businessScore cc.scoreBreakdown.relevance, km, notes ; A few hundred businesses come back from "outside your places" to a real score for the cost of a few hundred UPDATEs. The ones that never got as far as a fit judgement go back to discovered and get re-queued for the ordinary pipeline instead, which is what the scoreBreakdown ? "scored" : "discovered" ternary is doing: the row remembers how far it had got, so resuming does not mean restarting. This is the part I would push hardest if I were reviewing someone else's version of this feature. If a filter deletes rows, loosening the filter is a re-crawl and a re-spend. If a filter writes a reason on the row, loosening it is an arithmetic pass. The storage cost of keeping rejected rows is irrelevant next to the cost of judging them twice. The rescore pass can be hundreds or thousands of rows and the worker has a time budget, so it takes a deadline and returns "done" | "more" : if await rescoreBusinesses campaignId, job.createdAt, ctx.deadline === "more" throw new DeferJob ; There is no cursor and no offset. The watermark is the job's own createdAt , and the query skips anything whose scoredAt is already newer than that: or isNull campaignChannels.scoredAt , sql ${campaignChannels.scoredAt} < ${since} Every row the pass touches gets scoredAt stamped, so it excludes itself from the next pass. An interrupted run picks up exactly where it stopped, two runs of the same job cannot double-process a row, and there is no extra state to keep anywhere. The same trick runs the creator rescore in the same job handler, which is how I know it survives contact with a few thousand rows. If you want to see the customer-facing version of all of this, the business campaigns section https://nakodo.app/how-it-works businesses spells out the 200 a day, the nearest-first order, the chain rule and the email rules, and the small business page https://nakodo.app/for/small-businesses is the pitch those rules exist to support. The plan that includes them is on pricing https://nakodo.app/pricing .