DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One request a second for the whole app, so our place search has no search-as-you-type

Setting up a business campaign in Nakodo means saying where to look. You type a town, a city, a region or a country, press Add, choose which match you meant, and pick a distance. To search a places listing by coordinates, we need to turn "Leeds" into a latitude, a longitude and a sensible radius.

That is geocoding, and the paid options are fine and metered. We used Nominatim, OpenStreetMap's own geocoder, which is free, run on donated infrastructure, and governed by a usage policy that is short enough to read in a minute and specific enough to change your product.

The parts that matter:

  • An absolute maximum of one request per second. Not per user. Per application.
  • A valid User-Agent or Referer identifying the application.
  • Results must be cached on your side.
  • No autocomplete. The policy names search-as-you-type as an unacceptable use.

Most third-party limits arrive as a number you divide by, slap a retry on, and forget. This one is different, because one request per second shared across your entire user base cannot be absorbed by retrying. It has to be designed around, and the first thing it changes is the interface.

The UI decision comes first

There is no search-as-you-type in Nakodo's place picker. You type, nothing happens, you press Add (or Enter), one lookup goes out, and you pick from the matches it comes back with. That is not a performance compromise we are apologising for, it is the policy, and building the autocomplete first and discovering the policy second would have meant throwing the autocomplete away.

It also happens to be the right call for this specific field. A campaign has at most ten places and you add them once, when you set it up. The interaction cost of pressing Add is near zero. If this were a store locator used a hundred times a minute by shoppers, Nominatim would be the wrong dependency and the answer would be to pay somebody.

So: read the policy of the thing you are about to depend on before you design the component that depends on it.

A limiter that protects somebody else

The spacing is implemented with Upstash's Ratelimit, which is the same library we use for actual rate limiting, configured in a way that looks wrong until you say out loud what it is for:

const limiter = redis
  ? new Ratelimit({ redis, limiter: Ratelimit.fixedWindow(1, "1 s"), prefix: "rl:nominatim", ephemeralCache: false })
  : null;
Enter fullscreen mode Exit fullscreen mode

Two things are unusual.

The key is the constant string "global". A normal rate limiter is keyed on a user id or an IP, because its job is to stop one caller from hurting you. This one is keyed on nothing, because its job is to stop all of your callers together from hurting a third party. Every user of the app shares the single bucket, which is exactly what the policy asks for and the opposite of what a per-user limiter does.

ephemeralCache: false is the other one. That option keeps a per-process memory cache so an instance that has just been told "no" can answer locally instead of making another Redis round trip. It is a good optimisation for abuse protection and a correctness bug here: serverless means many instances, and each one with its own idea of the current second means N requests per second rather than one. The shared store has to be the only authority.

Waiting is the right response, not 429

Because the limiter is not protecting us from the user, refusing the user is the wrong behaviour. turn() queues instead:

async function turn(): Promise<void> {
  for (let i = 0; i < 10; i++) {
    if (limiter) {
      const { success, reset } = await limiter.limit("global");
      if (success) return;
      await sleep(Math.max(100, reset - Date.now()));
    } else {
      const wait = lastLocal + 1000 - Date.now();
      if (wait <= 0) { lastLocal = Date.now(); return; }
      await sleep(wait);
    }
  }
  throw new Error("Place search is busy");
}
Enter fullscreen mode Exit fullscreen mode

It sleeps until the window Upstash reports as reset, with a 100 ms floor so a clock difference cannot turn into a hot loop. Ten attempts, so in the pathological case the user gets a plain "Place search is busy" after a few seconds rather than a hung action, but the normal shape of a busy moment is a wait of under a second that the user never notices.

The else branch is the no-Redis fallback: one module-level timestamp, one process, one second. It is not correct across instances and it does not pretend to be. It exists so that a developer who has cloned the repo and not set up Upstash gets a working place search instead of a crash, and so that the honest degradation is written down in the code rather than assumed.

The cache the policy asks for, with the TTL the data deserves

const CACHE_SECONDS = 30 * 24 * 3600;
const key = `geo:${q.toLowerCase()}`;
Enter fullscreen mode Exit fullscreen mode

Thirty days, because Leeds will be in the same place next month. Cache TTLs are usually a guess about staleness tolerance; occasionally the data is genuinely immutable on human timescales and you can just say so. The query is whitespace-collapsed, trimmed, capped at 120 characters and lowercased before it becomes the key, so " leeds " and "Leeds" are one cache entry.

There is a memory-map fallback here too, with the least sophisticated eviction policy available:

if (memory.size > 500) memory.clear();
memory.set(key, matches);
Enter fullscreen mode Exit fullscreen mode

Dropping the whole map when it hits 500 entries is not an LRU and is not trying to be. This path only runs in a single-process development environment where the cache is a convenience, and an LRU implementation here would be code with no production consumer.

The User-Agent is assembled, not typed

The policy requires an identifying User-Agent. The temptation is a string literal:

const USER_AGENT = `${BRAND.name}/1.0 (+${SITE_URL})`;
Enter fullscreen mode Exit fullscreen mode

Both halves come from the constants the rest of the app uses for its name and its canonical URL. We renamed things recently, and every hardcoded copy of a brand name is a thing that silently becomes a lie. A User-Agent that identifies an application that no longer exists under that name is worse than no User-Agent, because the operator cannot contact you about your traffic.

Two parsing traps in the response

The first is that Nominatim will give you the same city twice, once as a point and once as a boundary relation. Deduplication is on the label we build, not on the raw payload, and we keep the first five of eight asked for:

const seen = new Set<string>();
for (const r of results) {
  const m = toMatch(r);
  if (!m || seen.has(m.label)) continue;
  seen.add(m.label);
  matches.push(m);
  if (matches.length === 5) break;
}
Enter fullscreen mode Exit fullscreen mode

The second is boundingbox, and it is the sort of thing that produces a bug you stare at for an hour:

type NominatimResult = {
  boundingbox: [string, string, string, string]; // south, north, west, east
};
Enter fullscreen mode Exit fullscreen mode

Nominatim orders it south, north, west, east. Almost everything else in the GIS world, including GeoJSON and the Parquet files our places data lives in, orders a bbox west, south, east, north. They are both four numbers and both plausible, so a mix-up does not throw, it just quietly points at a different part of the world. The array is also strings, not numbers. The code reorders explicitly and converts at the boundary:

const [south, north, west, east] = r.boundingbox.map(Number);
const bbox: [number, number, number, number] = [west, south, east, north];
Enter fullscreen mode Exit fullscreen mode

A country and a town are different kinds of thing

The result is one of two shapes, discriminated on Nominatim's addresstype:

if (r.addresstype === "country") {
  return { kind: "country", label: country, countryCode, lat: null, lng: null, radiusKm: null, bbox };
}
return { kind: "area", label: [...], countryCode, lat: ..., lng: ..., radiusKm: radiusForBox(...) };
Enter fullscreen mode Exit fullscreen mode

A country has an extent and no centre, because "500 km from the middle of France" is meaningless. A town has a centre and a radius. The three nullable fields move as a group, which the validation layer then enforces with a refinement rather than trusting it:

.refine((l) => (l.lat === null) === (l.lng === null) && (l.lat === null) === (l.radiusKm === null), {
  message: "A place is either a point with a radius or a whole country",
})
Enter fullscreen mode Exit fullscreen mode

That is a union being modelled as three nullable columns, which is a compromise with the database rather than a design preference, and the refinement is what stops it from silently becoming four states instead of two.

The suggested radius is half a diagonal, snapped

The user picks a distance, but the field is pre-filled with a guess, and the guess comes from the size of the thing they named:

export const RADII_KM = [2, 5, 10, 25, 50, 100, 200] as const;

export function radiusForBox([south, north, west, east]: number[]): number {
  const half = distanceKm({ lat: south, lng: west }, { lat: north, lng: east }) / 2;
  return RADII_KM.reduce<number>((best, r) => (Math.abs(r - half) < Math.abs(best - half) ? r : best), RADII_KM[0]);
}
Enter fullscreen mode Exit fullscreen mode

Half the great-circle diagonal of the boundary box, then snapped to the nearest offered option. Type a city and you get 10 or 25. Type a neighbourhood and you get 2. Type a county and you get 100. There is no cleverness here at all, which is the point: the offered values are a fixed list, so the mapping is a nearest-neighbour search over seven numbers and cannot produce a surprise.

Attribution, and where to see it

OpenStreetMap data is ODbL. Attribution is a licence term, not a nicety, and it goes on the product.

The place to see the result of all of the above is the business campaigns section of our methods page: nakodo.app/how-it-works#businesses. It states that place names are looked up in OpenStreetMap, and that a place is a town or city with a distance from 2 to 200 km, a region, or a whole country, which is the same two-shaped union and the same seven radii described above.

If you want to see the no-autocomplete decision rather than read about it, the setup flow is behind a sign-in at nakodo.app. Open the network tab on the place field: typing produces nothing at all, and pressing Add produces exactly one call to our own server, which is the only thing allowed to talk to Nominatim. That is a product behaviour written by somebody else's usage policy, which is a more common situation than the amount of code written about it suggests.

Top comments (0)