Posted in Engineering

Unicode Normalization Explained: NFC vs NFD and Why Text Comparison Bugs Happen

AT A GLANCE

FormWhat it doesTypical use
NFC (Normalization Form C)Canonical decomposition, then canonical composition — combines base letters with marks into single codepoints where possibleDefault for storage, URLs, and most text on the web
NFD (Normalization Form D)Canonical decomposition only — splits precomposed characters into base + combining marksText analysis, diacritic-insensitive search, some macOS filesystem APIs
NFKC (Normalization Form KC)Compatibility decomposition, then canonical composition — also folds stylistic/compatibility variants (e.g. ligatures, roman numerals) into plain equivalentsSearch, security checks on identifiers, loose matching
NFKD (Normalization Form KD)Compatibility decomposition only, no recompositionAggressive text normalization before further processing

Short Verdict

Unicode allows more than one sequence of codepoints to render as the same visible string. “café” typed on one system can be a single precomposed character (U+00E9, LATIN SMALL LETTER E WITH ACUTE) and on another a base letter plus a separate combining accent (U+0065 U+0301). Both look identical. Neither is “more correct.” But a naive == comparison, a database unique index, or a cache key built from the raw string will treat them as different values. The fix, per the Unicode Standard itself, is to normalize both sides to the same form — usually NFC — before comparing, storing, hashing, or indexing. This is not an edge case for CJK or emoji only; it hits any text with accents, umlauts, or combining marks, which means most non-English (and plenty of English) text that passes through more than one system.

What Unicode Normalization Actually Is

Unicode Standard Annex #15 (UAX #15), the Unicode Normalization Forms specification, defines the problem as canonical equivalence: “a fundamental equivalency between characters or sequences of characters” that represent the same abstract character and are supposed to look and behave identically when rendered correctly. A weaker relationship, compatibility equivalence, covers characters that are related in meaning but may look or behave differently — the roman numeral “Ⅰ” (U+2160) and the letter “I” (U+0049), for instance, which share meaning but not necessarily rendering.

Normalization resolves this by giving every equivalent sequence a single, well-defined representative form. UAX #15 defines four:

  • NFD — Canonical Decomposition: break every precomposed character into its base character plus combining marks.
  • NFC — Canonical Decomposition followed by Canonical Composition: decompose, then recombine every base-plus-mark sequence back into a single precomposed character wherever one exists.
  • NFKD — Compatibility Decomposition: like NFD, but also expands compatibility characters (ligatures, fullwidth forms, superscripts, roman numerals) into their plain equivalents.
  • NFKC — Compatibility Decomposition followed by Canonical Composition: NFKC and NFC differ only in that decomposition step.

The mechanics behind composition and decomposition matter for correctness, not just trivia. Every character carries a Canonical_Combining_Class; characters with class zero (“starters”) anchor a run, and non-zero-class combining marks following a starter get sorted by that class under the Canonical Ordering Algorithm — so the same accent marks typed in a different order still normalize identically. The Canonical Composition Algorithm then scans left to right and merges any eligible starter+mark pair into its precomposed codepoint, except for a documented set of characters that UAX #15 excludes from composition entirely (so they never get recombined, by design).

Two properties from the spec matter in practice. First, normalization is stable across Unicode versions: a string normalized under one version of Unicode stays normalized under every later version, which is why it’s safe to normalize once and store the result long-term. Second, and easy to miss, none of the four forms is closed under string concatenation — joining two independently-normalized strings (e.g. gluing a normalized prefix to a normalized suffix at a boundary) can produce a string that is no longer normalized, so systems that build text by concatenation need to re-normalize after assembly, not just at the edges.

Where This Breaks Real Systems

The classic failure mode: a user registers an account with a name typed on a Mac, which may produce decomposed sequences from certain input methods; a second system (a database, a search index, an API from a different vendor) receives the same visual string but precomposed, because its OS or browser normalized on input. "café" == "café" returns false even though a human reading both sees the same word. This shows up as: duplicate user accounts that look identical, failed login/username lookups, broken full-text search, HMAC or hash mismatches on supposedly-identical payloads, and inconsistent sort order. It is especially common at the boundary between macOS (whose HFS+/APFS filesystem APIs have historically normalized filenames toward a decomposed form) and systems on Linux or Windows that leave filenames exactly as given, byte for byte.

NFKC/NFKD introduce a second, security-relevant failure mode in the opposite direction: because they fold visually or semantically similar characters together, they can be used to defeat naive blocklists (a filter that blocks the ASCII word “admin” might not catch a fullwidth or ligature variant until it’s been NFKC-normalized) — but the same folding is exactly why UAX #15 warns compatibility forms “must not be blindly applied to arbitrary text”: they are lossy with respect to formatting and can change meaning (e.g. collapsing a trademark symbol into plain letters) in contexts where that distinction matters, such as user-facing display or legal text.

A Minimal, Correct Example

Python’s standard library implements all four forms via unicodedata.normalize(form, text), with form one of "NFC", "NFD", "NFKC", "NFKD":

import unicodedata

composed   = "caf\u00e9"          # NFC: single codepoint U+00E9 (é)
decomposed = "cafe\u0301"         # NFD: "e" + combining acute U+0301

print(composed == decomposed)
# False -- same visible text, different codepoint sequence

print(unicodedata.normalize("NFC", decomposed) == composed)
# True -- normalize both sides before comparing

print(unicodedata.is_normalized("NFC", composed))
# True -- cheap check before doing the full normalization pass

The Python docs flag the same root issue directly: “Even if two unicode strings are normalized and look the same to a human reader, if one has combining characters and the other doesn’t, they may not compare equal.” The fix is always the same shape — normalize to one form at the boundary (on input, before storage, before comparison) rather than trying to special-case every possible equivalent sequence.

Trade-offs: NFC vs. NFD vs. NFKC/NFKD

  • NFC pros: most compact representation; what most web content, URLs, and the W3C’s own guidance for HTML/XML use by default; round-trips cleanly through most legacy systems that expect precomposed accented letters.
  • NFC cons: not every combining sequence has a precomposed codepoint, so some marks never recompose — NFC does not guarantee a mark-free string.
  • NFD pros: useful when you need to isolate or strip diacritics (e.g. building an accent-insensitive search index by decomposing, then dropping combining-mark codepoints).
  • NFD cons: larger byte size; many older libraries and protocols assume precomposed input and mishandle bare combining marks.
  • NFKC/NFKD pros: good for loose matching, search, and some identifier-spoofing defenses, since it collapses compatibility variants that would otherwise bypass exact-match filters.
  • NFKC/NFKD cons: lossy and one-directional — you cannot recover the original formatting distinctions, so it is unsafe for anything that must preserve exact user input (legal names, file content, cryptographic payloads).

Who Should Use Which Form

Use NFC as the default for anything you store, compare, hash, index, or put in a URL — it’s the form most interoperable systems already expect. Use NFD only when you specifically need to manipulate base characters and marks separately, such as building a diacritic-folding search feature, or when working directly with macOS filesystem APIs that normalize toward it. Use NFKC/NFKD only for non-reversible operations like fuzzy search, spam/abuse filtering, or identifier collision checks — never for data you need to preserve exactly, and never as a silent step before storage, since you’d be discarding information users typed on purpose.

Bottom Line

Most “duplicate account” and “search can’t find text that’s clearly there” bugs involving accented or non-Latin text trace back to one root cause: two systems chose different, equally valid, codepoint sequences for the same visible string, and nobody normalized before comparing. Pick NFC as your storage and comparison default, normalize at every system boundary (not just once at the start), and reach for NFKC/NFKD deliberately and only for lossy, non-reversible matching — never as an invisible default.

FAQ

Is NFC “more correct” than NFD? No. Both represent the exact same abstract text under Unicode’s canonical equivalence; NFC is simply the more common interoperability default because it’s more compact and matches what most legacy encodings and the web already expect.

Does normalizing fix emoji or CJK comparison bugs too? It fixes any text built from combining sequences or compatibility variants that have multiple valid representations — this includes many CJK compatibility ideographs and some emoji ZWJ sequences, though the exact equivalence rules differ by category and UAX #15 should be checked for edge cases.

Can I normalize once and never worry about it again? Normalization is stable across Unicode versions, so a string normalized today stays normalized later — but concatenating two normalized strings is not guaranteed to produce a normalized result, so re-normalize after any operation that joins or edits text, not just at ingestion.

Is NFKC safe for security filtering (e.g. blocking reserved usernames)? It helps catch some lookalike-character bypasses, but it is lossy by design and should be paired with other checks (such as Unicode confusables detection), not treated as a complete defense on its own.

Sources

Related reading on this site: Parsing Explained: Recursive Descent, Parser Combinators, and PEG Grammars and Bloom Filters Explained.

Posted in Engineering

Pathfinding Algorithms Explained: Dijkstra, A* and How Maps and Games Navigate Graphs

Every time a phone reroutes you around traffic, or a game character walks around a wall instead of into it, some version of the same two algorithms is running underneath. Dijkstra’s algorithm and A* (pronounced “A-star”) search are the two foundational techniques for finding the shortest path through a graph, and understanding one is mostly a matter of understanding the other with one extra idea added on top.

At a Glance

ConceptUsed ByComplexity
Dijkstra’s AlgorithmLink-state network routing protocols (OSPF, IS-IS), uniform-cost search in AI, and as the base layer under many road-routing engines’ preprocessing pipelinesO((V + E) log V) with a binary heap; O(E + V log V) with a Fibonacci heap
A* SearchGame engine navigation (e.g. Unity’s NavMesh pathfinder), robotics path planning (built originally for the Shakey robot), turn-based and real-time strategy AIO(b^d) worst case (b = branching factor, d = solution depth); reduces to Dijkstra’s bound when the heuristic is zero
Greedy Best-First SearchFast approximate routing where a guaranteed-shortest path isn’t requiredTypically faster in practice, but offers no optimality guarantee

The Problem Both Algorithms Solve

Represent a road network, a game level, or a computer network as a graph: nodes are intersections, grid cells, or routers, and edges are roads, walkable connections, or network links, each carrying a weight (distance, time, or cost). The shortest-path problem asks for the lowest-total-weight route between two nodes. That abstraction is general enough to cover GPS navigation, in-game AI movement, and internet routing with the same underlying math.

Dijkstra’s Algorithm: Growing the Known Region

Edsger Dijkstra conceived the algorithm in 1956 as a demonstration for the ARMAC computer, using a map of 64 Dutch cities, and published it in 1959. It works by maintaining a set of “settled” nodes whose shortest distance from the start is already known, and repeatedly pulling the closest not-yet-settled node out of a priority queue. For each neighbor of that node, it checks whether reaching the neighbor through the current node is cheaper than any route found so far, and updates the neighbor’s distance if so. The node is then marked settled, and the process repeats until the target is reached or the queue is empty.

The algorithm’s complexity depends entirely on how that priority queue is implemented. A simple array scan gives O(V²). A binary heap brings it to O((V + E) log V), which is efficient on sparse graphs like road networks. A Fibonacci heap, introduced by Fredman and Tarjan in 1984, gives the best known theoretical bound of O(E + V log V) — though in practice binary heaps often win anyway, because Fibonacci heaps carry larger constant-factor overhead.

A* Search: Dijkstra Plus a Heuristic

Peter Hart, Nils Nilsson, and Bertram Raphael published A* in 1968 at the Stanford Research Institute, built for the Shakey robot path-planning project. A* runs the same frontier-expansion loop as Dijkstra, but instead of ordering the queue purely by the known cost-so-far, g(n), it adds an estimate of the remaining cost to the goal, h(n), and expands nodes in order of f(n) = g(n) + h(n). That estimate — the heuristic — is what lets A* pull the search toward the goal instead of spreading evenly in every direction.

The relationship between the two algorithms is direct: Dijkstra’s algorithm is the special case of A* where h(n) is zero everywhere. Set the heuristic to zero and A* degenerates exactly into Dijkstra. Make the heuristic dominate the cost-so-far term and A* starts behaving like Greedy Best-First Search, which chases the goal fast but can commit to routes that aren’t actually shortest.

Why the Heuristic Has to Behave: Admissibility and Consistency

A heuristic is admissible if it never overestimates the true remaining cost to the goal. On a square grid where movement costs 1 per step, Manhattan distance (the sum of the absolute coordinate differences) is a standard admissible heuristic, because it can never exceed the real number of moves required. As long as the heuristic stays admissible, A* is guaranteed to find an optimal path, inheriting the same optimality guarantee Dijkstra’s algorithm already has on its own.

A stronger property, consistency (sometimes called monotonicity), requires that for every edge from x to y, h(x) ≤ cost(x, y) + h(y) — essentially a triangle inequality on the heuristic. A consistent heuristic guarantees that once a node is expanded, A* never needs to re-expand it with a better cost later, which is what makes the standard “closed set” implementation correct and efficient.

A Minimal Implementation

The two algorithms differ by exactly one term in the priority queue ordering. Here they are side by side in Python, using a binary heap via the standard library:

import heapq

def dijkstra(graph, start, goal):
    g_score = {start: 0}
    came_from = {start: None}
    frontier = [(0, start)]
    while frontier:
        dist, current = heapq.heappop(frontier)
        if current == goal:
            break
        for neighbor, weight in graph[current]:
            new_dist = dist + weight
            if neighbor not in g_score or new_dist < g_score[neighbor]:
                g_score[neighbor] = new_dist
                came_from[neighbor] = current
                heapq.heappush(frontier, (new_dist, neighbor))
    return g_score, came_from


def a_star(graph, start, goal, heuristic):
    g_score = {start: 0}
    came_from = {start: None}
    frontier = [(heuristic(start, goal), start)]
    while frontier:
        _, current = heapq.heappop(frontier)
        if current == goal:
            break
        for neighbor, weight in graph[current]:
            new_g = g_score[current] + weight
            if neighbor not in g_score or new_g < g_score[neighbor]:
                g_score[neighbor] = new_g
                priority = new_g + heuristic(neighbor, goal)
                heapq.heappush(frontier, (priority, neighbor))
                came_from[neighbor] = current
    return g_score, came_from


def manhattan_heuristic(a, b):
    return abs(a[0] - b[0]) + abs(a[1] - b[1])

The only structural change in a_star is that the priority pushed onto the heap is new_g + heuristic(neighbor, goal) instead of just new_dist. Everything else — the loop, the relaxation step, the path reconstruction via came_from — is identical.

Where These Algorithms Actually Run

Unity’s NavMesh system is a documented, concrete example of A* in production: Unity builds a navigation mesh of walkable convex polygons from scene geometry, then uses the A* algorithm to search outward across neighboring polygons from the start polygon to the destination polygon, producing a “corridor” the agent then steers through. Off-mesh links handle actions the mesh can’t represent, like jumping a gap, and the pathfinder treats them as valid route segments.

On the networking side, link-state routing protocols such as OSPF and IS-IS run a form of Dijkstra’s algorithm directly: every router computes shortest paths to every other router from its own local view of the network graph. Road-routing software generally keeps Dijkstra (or a close relative, uniform-cost search) as the correctness backbone, but layers heavy preprocessing on top — bidirectional search, or techniques like contraction hierarchies — because running plain Dijkstra over a continent-scale road graph on every query would be far too slow. A* itself remains the default choice in robotics and games specifically because a good spatial heuristic (straight-line or Manhattan distance to the goal) is cheap to compute and dramatically cuts down the explored search space versus Dijkstra’s undirected expansion.

Trade-offs at a Glance

FactorDijkstra’s AlgorithmA* Search
Optimality guaranteeYes, alwaysYes, provided the heuristic is admissible
Search directionExpands uniformly outward from the start in all directionsExpands preferentially toward the goal
Heuristic requiredNoYes — quality of the heuristic drives performance
Best suited toFinding shortest paths from one source to many/all destinations (e.g. routing-table computation)Finding a path to one specific, known destination (e.g. navigation, game AI)
Worst-case complexityO((V + E) log V) with a binary heapSame bound as Dijkstra when h(n) = 0; can be far lower with a strong heuristic

Bottom Line

Dijkstra’s algorithm and A* aren’t competing techniques so much as the same technique with and without a goal-directed hint. If you need shortest paths to many destinations at once, or have no useful way to estimate remaining distance, Dijkstra is the correct and simpler tool. If you have a single known destination and a cheap, admissible way to estimate how far you still have to go — true of almost every grid-based game level and most point-to-point navigation problems — A* will typically explore a fraction of the nodes Dijkstra would, while still guaranteeing the shortest route.

FAQ

Is A* always faster than Dijkstra’s algorithm? In practice, yes, when the heuristic is informative — it explores fewer nodes by focusing toward the goal. With a heuristic of zero, A* and Dijkstra do identical work, since A* degenerates into Dijkstra exactly in that case.

What makes a heuristic “admissible”? It must never overestimate the true remaining cost to the goal. Manhattan distance on a uniform-cost grid and straight-line (Euclidean) distance on a Euclidean map are standard admissible heuristics, because the real path cost can never be shorter than the straight-line distance.

Can A* find the wrong (non-shortest) path? Only if the heuristic overestimates the true cost somewhere — i.e., it isn’t admissible. An inadmissible heuristic can make A* behave like Greedy Best-First Search, trading guaranteed optimality for speed.

Why does Dijkstra’s algorithm matter if A* generalizes it? Many problems don’t have a single destination or a useful heuristic — computing shortest paths to every node from one source, as link-state routing protocols do, is exactly the case A* offers no advantage over.

What’s the practical downside of A*? Memory. Like Dijkstra, it stores information for every node it has generated, which is why memory-bounded variants such as IDA* and SMA* exist for very large search spaces.

Sources

Posted in Engineering

Rate Limiting Algorithms Explained: Token Bucket vs Sliding Window vs Leaky Bucket

Every public API eventually needs a way to say “slow down.” Rate limiting protects backends from overload, keeps one noisy client from starving everyone else, and turns an otherwise unpredictable traffic spike into a predictable 429 response. But “add rate limiting” isn’t one algorithm — it’s a choice between at least four meaningfully different approaches, each with its own memory cost, burst behavior, and accuracy trade-off. This explainer walks through token bucket, leaky bucket, fixed window, and sliding window counters, grounded in how AWS API Gateway and Stripe actually implement and document their own limits.

At a Glance

AlgorithmAllows bursts?Memory per clientAccuracyUsed by
Token bucketYes, up to bucket capacityO(1) — token count + timestampExact against its own modelAWS API Gateway, Stripe (recommended client-side)
Leaky bucketNo — smooths output to a constant rateO(1) — queue size + rateExact, but adds queuing latencyNetwork traffic shaping, some CDNs
Fixed window counterYes, up to 2x at window boundariesO(1) — one counter per windowAllows boundary burst flawSimple API quotas, legacy systems
Sliding window logNoO(n) — timestamp per requestExactLow-volume, high-precision limits
Sliding window counterSmall, boundedO(1) — two counters, weightedClose approximationCloudflare-style edge rate limiting, Redis-based limiters

Fixed Window Counter: The Naive Starting Point

The simplest approach divides time into fixed windows (say, one-second or one-minute buckets) and keeps a single counter per client per window. A request increments the counter; once the counter hits the limit, further requests are rejected until the window resets. It’s cheap — one integer per client — but it has a well-known flaw: a client can send its full quota at the very end of one window and its full quota again at the very start of the next, doubling its effective rate in a short burst spanning the boundary. For anything beyond a rough quota, this flaw usually rules fixed windows out.

Sliding Window: Fixing the Boundary Problem

Sliding window approaches fix the boundary-burst flaw by not resetting cleanly at fixed intervals. There are two common implementations:

  • Sliding window log: store a timestamp for every request in a client’s recent history (often in a sorted set), and on each new request, count how many timestamps fall within the trailing window. This is exact, but memory grows with request volume — O(n) per client — which makes it expensive at high request rates.
  • Sliding window counter: keep two fixed-window counters (current and previous) and estimate the sliding count as a weighted combination — for example, the current window’s count plus a fraction of the previous window’s count proportional to how much of that previous window still overlaps the trailing interval. This keeps memory at O(1) per client while closely approximating the exact sliding log, and it’s the approach most commonly cited in CDN- and edge-level rate limiting write-ups because it balances precision against the cost of tracking state for millions of concurrent clients.

Token Bucket: The Industry Default

Token bucket is the algorithm most production API gateways actually ship. AWS documents it directly for API Gateway: each client bucket holds tokens, tokens are added at a constant steady-state rate (requests per second), and each incoming request consumes one token. AWS’s own developer guide defines the two knobs plainly: the rate limit is “the rate, in requests per second, that tokens are added to the token bucket,” and the burst limit is “the capacity of the token bucket” — the maximum number of concurrent request submissions API Gateway will fulfill before returning 429 Too Many Requests. When the bucket is full, a client can momentarily burst well above the steady-state rate; once empty, requests are throttled until tokens refill. AWS is explicit that these are “best-effort” targets rather than hard guarantees, since other regional and account-level limits can also apply.

Stripe takes the same approach from the other side of the API relationship: its documented global rate limit is 100 requests per second in live mode and 25 requests per second in its sandbox, measured per Stripe account, with lower per-endpoint limits layered on top (for example, 25 requests per second for most individual endpoints, 20 read/write per second for the Files API). Stripe’s own documentation explicitly recommends that client integrations implement “a client-side token bucket rate-limiting algorithm” to smooth their own request rate below these ceilings, rather than hammering the API and relying on retries.

Token bucket’s appeal is that one simple state — a token count and a last-refill timestamp — captures both a sustained rate and a controlled burst allowance, which matches real traffic (which is rarely perfectly smooth) better than a hard per-second cap would.

Leaky Bucket: Smoothing Instead of Bursting

Leaky bucket flips the token bucket’s intent. Instead of allowing bursts up to a capacity, incoming requests fill a queue (the “bucket”), and that queue drains — is processed — at a constant, fixed rate, regardless of how bursty the input was. If the queue fills up, new requests are dropped or rejected. This produces a perfectly smoothed output rate, which is valuable when the thing being protected (a downstream service, a physical network link) genuinely cannot tolerate bursts at all, but it adds queuing latency that token bucket doesn’t: a request arriving when the queue is nearly full waits longer before being served, even if it would otherwise be well within an average rate. Leaky bucket is more associated with network traffic shaping than with modern HTTP APIs, where client-perceived latency usually matters more than perfectly flat throughput.

A Minimal Token Bucket in Pseudocode

struct Bucket:
    tokens: float
    capacity: float
    refill_rate: float  // tokens per second
    last_refill: timestamp

function allow_request(bucket):
    now = current_time()
    elapsed = now - bucket.last_refill
    bucket.tokens = min(bucket.capacity, bucket.tokens + elapsed * bucket.refill_rate)
    bucket.last_refill = now

    if bucket.tokens >= 1:
        bucket.tokens = bucket.tokens - 1
        return true   // allowed
    else:
        return false  // reject with 429

Choosing Between Them

  • Token bucket — default choice for most public APIs and gateways (AWS API Gateway, Stripe’s own guidance to clients). Good when occasional legitimate bursts are fine and you want O(1) memory per client.
  • Leaky bucket — best when downstream capacity is rigid and you need a perfectly smoothed output rate, accepting added latency.
  • Fixed window counter — fine for coarse, low-stakes quotas (e.g., “1,000 requests per day”) where the boundary-burst flaw doesn’t matter much.
  • Sliding window log — use when exactness matters and request volume per client is low enough that O(n) memory is affordable.
  • Sliding window counter — the practical middle ground for high-volume, distributed rate limiting (e.g., Redis-backed limiters at the edge), trading a small approximation error for O(1) memory.

Most real systems layer several of these: a token bucket or sliding window counter at the account level, tighter fixed windows on specific expensive endpoints, and a hard concurrency limit (a separate mechanism from rate limiting, capping simultaneous in-flight requests rather than requests per second) for long-running operations. Stripe, for instance, documents its global per-account rate limit and its separate concurrency limit — 50 in-progress requests by default in live mode — as two distinct protections that trigger different error headers.

Trade-offs Summary

  • Pros of token bucket: O(1) memory, tolerates natural burstiness, easy to reason about with two parameters (rate, capacity).
  • Cons of token bucket: a client can burst its entire bucket instantly if it has been idle, which can momentarily spike downstream load.
  • Pros of leaky bucket: perfectly smooth output rate, protects fragile downstream systems.
  • Cons of leaky bucket: adds queuing delay; a sudden burst is delayed rather than rejected, which can be worse for latency-sensitive clients.
  • Pros of sliding window counter: closely approximates exact behavior at O(1) memory, avoids the fixed-window boundary flaw.
  • Cons of sliding window counter: still an approximation, with a small, bounded error versus a true sliding log.

The Bottom Line

There’s no single “best” rate limiting algorithm — there’s a best algorithm for a given traffic pattern and failure mode. Token bucket is the closest thing to an industry default because it’s cheap, tolerates real-world burstiness, and is what both a major cloud provider (AWS API Gateway) and a major API company (Stripe) actually document and recommend. Leaky bucket earns its place when burst tolerance is actively undesirable. Sliding window counters are the pragmatic choice when you need sliding-window accuracy without sliding-log memory costs. Knowing all four means you can match the algorithm to the actual failure you’re trying to prevent, instead of defaulting to whatever a library ships.

FAQ

What HTTP status code signals rate limiting?
Almost universally 429 Too Many Requests, often with a Retry-After header. Stripe additionally returns a Stripe-Rate-Limited-Reason header identifying whether the limit was global, endpoint-specific, or a concurrency limit.

Is rate limiting the same as throttling?
In practice the terms are used interchangeably, including in AWS’s own documentation, which describes “throttling” as the mechanism and the token bucket as the algorithm behind it.

Is a concurrency limit a type of rate limit?
No — it’s a related but distinct protection. A rate limit caps requests per unit time; a concurrency limit caps how many requests can be in-flight simultaneously, regardless of how fast they arrive. Stripe documents both separately, since long-running or resource-intensive requests can exhaust concurrency without ever exceeding the rate limit.

Should I rate limit on the client side too?
Yes, and both AWS and Stripe effectively recommend it. Stripe’s documentation specifically suggests client-side token bucket limiting with exponential backoff and jitter, so a client self-regulates instead of discovering the limit only via repeated 429 errors.

Which algorithm should a new API default to?
Token bucket, unless you have a specific reason to smooth output (leaky bucket) or need exact sliding-window accuracy at low volume (sliding window log). It’s well understood, cheap to implement, and matches how both AWS and Stripe limit their own APIs.

Sources

Related reading on this site: Secure CI/CD Pipelines: A Practical Checklist for Supply-Chain Security in 2026 and Consistent Hashing Explained, which covers another algorithm that quietly runs underneath load balancers and distributed caches.

Posted in Engineering

Vector Databases and Approximate Nearest Neighbor Search Explained: HNSW vs IVF for AI Retrieval

Every retrieval-augmented generation (RAG) pipeline, semantic search box, and recommendation engine built since 2023 depends on the same underlying problem: given a query vector, find the handful of vectors in a database of millions (or billions) that are closest to it. Doing this exactly, by comparing the query to every stored vector, is called k-nearest neighbor (kNN) search, and it does not scale — brute-force comparison is O(N) per query. So almost every real vector database instead does approximate nearest neighbor (ANN) search: trading a small, tunable amount of recall for orders-of-magnitude faster lookups. Two families of ANN algorithm dominate production systems in 2026: graph-based methods, led by HNSW, and partition-based methods, led by IVF. This piece explains how each actually works, what their real trade-offs are, and when to reach for which.

At a Glance

FactorHNSW (graph-based)IVF (partition-based)
Core ideaMulti-layer navigable graph; search by walking edges toward the queryCluster vectors into cells with k-means; search only the nearest cells
Key build parametersM (connections per node), efConstructionnlist (number of cells)
Key query parameterefSearchnprobe (cells searched)
Query speedVery fast, roughly logarithmic in dataset sizeFast, scales with nprobe/nlist fraction scanned
Memory overheadHigher — stores the full graph on top of vectors (~M × 2 × 4 extra bytes/vector)Lower — mainly list IDs plus chosen vector encoding
Build timeSlower to build, especially at high M/efConstructionFaster to build; k-means training step is comparatively cheap
Handles deletions well?No — removing nodes breaks the graph structureYes — easier to add/remove vectors from a cell
Typical usersQdrant, Weaviate, Milvus (default), pgvector (HNSW index)FAISS, Milvus (IVF variants), many large-scale offline pipelines

Why Exact Search Doesn’t Scale

An embedding model turns text, images, or audio into a dense vector, typically a few hundred to a few thousand floating-point numbers, positioned so that semantically similar inputs land close together under a distance metric like cosine similarity or Euclidean distance. Finding the true nearest neighbors to a query vector means computing that distance against every vector in the collection. At a few thousand vectors this is trivial. At tens of millions, it means billions of floating-point operations per query — workable for a batch job, unworkable for an interactive RAG endpoint that needs to return results in tens of milliseconds. ANN algorithms accept a small, measurable chance of missing a true nearest neighbor in exchange for search that scales sub-linearly.

How HNSW Works

HNSW (Hierarchical Navigable Small World) was introduced by Yu. A. Malkov and D. A. Yashunin in their 2016 paper “Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs,” building on earlier skip-list and small-world-graph ideas. Conceptually, HNSW organizes vectors into several stacked graph layers, like a multi-level transit map:

  • The top layer is sparse, with a small number of nodes connected over long links — good for covering large distances quickly.
  • Each layer below is progressively denser, with more nodes and shorter, more local connections.
  • The bottom layer contains every vector in the collection, densely interconnected.

A search starts at an entry point in the top layer and greedily walks toward the query vector, hopping to whichever neighbor is closer at each step. When no neighbor in the current layer is closer, the search drops down a layer and continues, refining its position until it bottoms out with a set of nearby candidates. This is similar to zooming from a country-level map down to a street-level map rather than scanning every street in the country directly.

Three parameters control the trade-off, as documented by vector database vendors such as Qdrant:

  • M — the maximum number of connections per node (commonly 8–64; Qdrant defaults to 16). Higher M improves recall but increases memory and build time.
  • efConstruction — how many candidate neighbors are evaluated when inserting a new vector into the graph (commonly 100–500). Higher values build a better-connected graph at the cost of slower indexing.
  • efSearch (sometimes called hnsw_ef) — how many candidates are evaluated per query at search time (commonly 50–200+). This is the main dial for trading query latency against recall, and unlike the other two, it can be changed per query without rebuilding the index.

HNSW’s practical downside is memory and mutability. The graph’s edges sit on top of the raw (or quantized) vectors, adding roughly M × 2 × 4 bytes per vector according to FAISS’s own documentation, and removing a vector cleanly is not supported because deleting a node can disconnect the graph around it — most implementations instead mark vectors as deleted and periodically rebuild.

How IVF Works

IVF (Inverted File index), as implemented in Meta’s FAISS library, takes a completely different approach: partition first, then search only the relevant partitions. FAISS’s own wiki documentation describes it as a “cell-probe method”:

  • During index training, a coarse quantizer (usually k-means) splits the vector space into nlist cells, each represented by a centroid.
  • Every vector in the collection is assigned to exactly one cell — whichever centroid it’s closest to — and stored in that cell’s inverted list.
  • At query time, the query vector is compared only against the nlist centroids to find the nprobe closest cells, then compared against the actual vectors inside just those cells.

FAISS’s documentation gives a rule of thumb for sizing nlist at roughly C × sqrt(n) for a dataset of n vectors. The fraction of the database actually scanned per query is approximately nprobe / nlist (in practice usually a bit more, since cells are rarely perfectly balanced). Raising nprobe improves recall by checking more cells, at a roughly linear cost in query time. Recall suffers specifically when a query’s true nearest neighbor happens to sit in a cell that wasn’t among the nprobe probed — a structural risk near cell boundaries that has no direct HNSW equivalent.

IVF is commonly paired with vector compression. IVFFlat stores full-precision vectors (about 4d + 8 bytes per vector, where d is dimensionality); IVFPQ (product quantization) compresses vectors much further at some recall cost, making it attractive for billion-scale, memory-constrained deployments. Because cells are just lists, adding and removing vectors is straightforward compared to HNSW’s graph surgery problem.

A Minimal Illustration of the Search Logic

The pseudocode below is a simplified sketch of the two query paths — not production code — meant only to show where each algorithm spends its effort.

function ivf_search(query, nprobe):
    centroids = get_cell_centroids()
    nearest_cells = top_k(centroids, query, k=nprobe)
    candidates = []
    for cell in nearest_cells:
        candidates.extend(cell.vectors)
    return top_k(candidates, query, k=final_k)

function hnsw_search(query, ef_search):
    layer = top_layer
    current = entry_point
    while layer > 0:
        current = greedy_walk(layer, current, query)
        layer = layer - 1
    candidates = explore_neighbors(current, query, ef=ef_search)
    return top_k(candidates, query, k=final_k)

HNSW vs IVF: When to Use Which

Neither structure is strictly better; they sit at different points on the recall/speed/memory curve, and the right choice depends on dataset size, update frequency, and hardware budget.

  • Choose HNSW when query latency matters most, the dataset fits comfortably in memory, recall needs to stay high even at low latency, and updates are frequent but deletions are rare (most RAG and real-time semantic search use cases). It’s the default or primary index type in Qdrant, Weaviate, and pgvector’s HNSW mode.
  • Choose IVF (often IVF+PQ) when the collection is very large (hundreds of millions to billions of vectors), memory is tight, and some recall can be traded for dramatically smaller index size. It’s also easier to shard and rebuild incrementally, which is why it shows up heavily in FAISS-based offline and batch retrieval pipelines.
  • Hybrid approaches exist: FAISS supports using an HNSW graph as the coarse quantizer inside an IVF index (IVF_HNSW), combining fast coarse routing with inverted-list storage.

In practice, most teams don’t hand-pick an algorithm from first principles — they pick a vector database (Qdrant, Weaviate, Pinecone, Milvus, or Postgres with pgvector) and then tune its exposed parameters. Pinecone’s newer serverless indexes go a step further and abstract the index type away entirely, separating storage from query compute and exposing on-demand read capacity rather than a pod size the user has to choose. That’s a trend worth watching: the HNSW-vs-IVF decision is increasingly made by the database vendor, not the application developer, with the practitioner left tuning recall targets and cost rather than graph parameters directly.

Trade-offs Summary

  • Pros of HNSW: excellent recall-to-latency ratio, logarithmic-ish query scaling, tunable at query time via efSearch without rebuilding.
  • Cons of HNSW: larger memory footprint, slower builds at high quality settings, poor support for deletions.
  • Pros of IVF: lower memory overhead (especially with PQ), faster builds, straightforward updates and deletions, scales well to very large, sharded datasets.
  • Cons of IVF: recall is more sensitive to data distribution and cell boundaries; needs careful nlist/nprobe tuning and periodic retraining of centroids as data shifts.

The Bottom Line

HNSW and IVF solve the same problem — fast approximate retrieval over dense vectors — with opposite structural bets: a navigable graph versus a partitioned index. For most RAG and application-facing semantic search workloads where the data fits in memory and queries need to be fast and fresh, HNSW’s recall-per-millisecond is hard to beat, which is why it has become the default in most purpose-built vector databases. For very large-scale, storage-constrained, or heavily batch-oriented retrieval, IVF (particularly IVF+PQ) remains the more economical choice. The practical skill isn’t memorizing one algorithm — it’s knowing which knobs (efSearch, nprobe, M, nlist) to turn when recall, latency, or memory becomes the bottleneck in production.

FAQ

Is ANN search ever exact?
No — by definition, approximate nearest neighbor search can miss some true nearest neighbors. Both HNSW and IVF expose parameters (efSearch, nprobe) that push recall closer to exact search as you increase them, at the cost of query speed.

Can I combine HNSW and IVF?
Yes. FAISS supports using an HNSW graph as the coarse quantizer inside an IVF index, and several systems layer quantization (scalar or product quantization) on top of either structure to shrink memory further.

Which one do most vector databases use by default?
Most purpose-built vector databases aimed at application developers — Qdrant, Weaviate, Milvus’s default configuration, pgvector’s HNSW index type — default to HNSW because of its strong recall-to-latency ratio on in-memory datasets. IVF (and IVF+PQ specifically) is more common in large-scale, cost-sensitive, or research/offline retrieval pipelines built directly on FAISS.

Does deleting vectors work the same way in both?
No. FAISS’s own documentation notes that HNSW does not support true vector removal because deleting a node can disconnect the graph; most systems instead tombstone and periodically rebuild. IVF’s list-based structure makes removal and insertion more straightforward.

How does this relate to RAG?
A RAG pipeline embeds a user’s query, runs ANN search (HNSW or IVF, depending on the vector store) against a corpus of pre-embedded chunks, and feeds the top results into an LLM’s context window as retrieved evidence. The ANN index’s recall directly caps how good the retrieved context — and therefore the generated answer — can be.

Sources

Related reading on this site: B-Trees vs LSM-Trees: How Databases Choose Their Storage Engine and Oracle Database 23ai and the Oracle–Nvidia Partnership, which covers vector search built directly into a relational database engine.

Posted in Engineering

Bloom Filters Explained: The Probabilistic Data Structure Behind Fast Lookups in Databases and CDNs

Databases and CDNs handle billions of lookups a day where the honest answer is almost always “no” — this key isn’t cached, this partition doesn’t hold that row, this URL isn’t on a blocklist. Hitting disk, making a network call, or scanning a table just to confirm an absence is expensive at that scale. The Bloom filter is the classic fix: a compact, probabilistic structure that can say “definitely not in the set” instantly, and “probably in the set” the rest of the time — never the reverse. Invented by Burton H. Bloom in 1970 to cut down expensive dictionary lookups, it now sits quietly inside Apache Cassandra, a PostgreSQL index type, Akamai’s CDN caching logic, and (historically) Chrome’s malicious-URL checks. Here’s exactly how it works, the math behind tuning it, and where it’s verifiably used today.

At a Glance

What it isA fixed-size bit array plus k independent hash functions, used to test set membership
GuaranteesZero false negatives — a “not in the set” answer is certain. False positives are possible, and their rate is tunable.
Typical use casesSkipping unnecessary disk reads, network round-trips, or cache lookups for keys that almost certainly don’t exist
Space costAbout 9.6 bits per item for a 1% false-positive rate — independent of how large the items themselves are
Trade-offsCan’t enumerate members, standard version can’t delete items, and accuracy degrades once more items are inserted than it was sized for
OriginBurton H. Bloom, “Space/Time Trade-offs in Hash Coding with Allowable Errors,” Communications of the ACM (1970)

How a Bloom Filter Works

A Bloom filter is just an array of m bits, all starting at 0, plus k independent hash functions that each map any item to one position in that array.

  • Insert: run the item through all k hash functions and set each resulting bit position to 1.
  • Query: run the candidate item through the same k hash functions. If any of those bit positions is 0, the item was definitely never inserted. If all of them are 1, the item is probably in the set.

The “definitely not” answer is airtight: a bit can only be set by an insertion, so a 0 means no insertion ever touched it. The “probably yes” answer is where the trade-off lives. Because many items share the same small bit array, two or more different items can collectively set all k bits that a third, never-inserted item would also hash to. That coincidence is a false positive — the filter claims membership for something that was never added. This is also why standard Bloom filters can’t support deletion: clearing a bit to remove one item might silently remove another item that happens to share that bit.

The Math: Choosing Size and Hash Count for a Target False-Positive Rate

Three numbers determine everything: m (bits in the array), n (items you plan to insert), and k (hash functions). For a filter with m bits holding n items and using k hash functions, the probability of a false positive is approximately:

p ≈ (1 − e−kn/m)k

For a fixed ratio of bits to items (m/n), there’s a value of k that minimizes this probability:

koptimal = (m/n) × ln 2

And working backward from a target false-positive rate p, the array size needed for n items is:

m = −(n × ln p) / (ln 2)2

Plugging in a 1% target (p = 0.01) gives m/n ≈ 9.6 bits per item and k ≈ 7 hash functions — and dropping the target rate by another order of magnitude costs only about 4.8 more bits per item, not ten times more space. This is a textbook result, and it checks out against a real deployed system: early Chrome Safe Browsing shipped a client-side Bloom filter sized at roughly 10 bits per URL hash with 7 hash functions, landing almost exactly on the formula’s 1%-false-positive point for a filter of that size — for a list of around 100 million malicious URLs, that compressed an ~8 GB blocklist into roughly 125 MB on-device.

Real-World Uses (Verified)

The mechanics above are standard computer science, but it’s worth separating the textbook idea from where it’s actually proven to run in production. Here are four uses confirmed against primary sources:

SystemHow it uses a Bloom filter
Apache CassandraEvery SSTable on disk carries a Bloom filter so a read can skip SSTables that provably don’t contain the requested partition, avoiding needless disk I/O. The target false-positive rate is a per-table setting (bloom_filter_fp_chance), defaulting to 0.01–0.1 depending on compaction strategy, per Cassandra’s own operations documentation.
PostgreSQL (bloom module)The official bloom contrib extension is an index access method that stores a lossy multi-column signature per row so queries can quickly exclude non-matching rows before rechecking the actual heap row — useful for equality filters across many columns where building one B-tree per column would be wasteful.
Google Chrome (historical)Early versions of Chrome’s Safe Browsing feature stored a Bloom filter of malicious-URL hashes on the device itself, so most page loads were cleared instantly with no network call; only a filter “hit” triggered a narrow server lookup. Chrome has since moved to a different hash-prefix design, but the original implementation is preserved in Chromium’s source history.
Akamai CDNIn the peer-reviewed “Algorithmic Nuggets in Content Delivery” paper, Akamai researchers Bruce Maggs and Ramesh Sitaraman describe using a Bloom filter to detect “one-hit wonders” — objects requested only once — and apply a cache-on-second-hit rule so first-time requests aren’t written to disk. Their traffic logs found over three-quarters of objects were one-hit wonders, and the filtering raised byte hit rate from about 74% to 83% while cutting disk writes by 44%.

Cassandra’s SSTable Bloom filters work alongside two other structural decisions covered in more depth elsewhere on this site: the choice between B-tree and LSM-tree storage engines (Cassandra is LSM-based, which is exactly why unnecessary SSTable reads are worth avoiding), and consistent hashing, which decides which node a key lives on before a Bloom filter ever gets asked whether that node’s local data might contain it.

Variants: Counting Bloom Filters and Cuckoo Filters

Two well-known extensions address the standard filter’s biggest limitation — no deletions:

  • Counting Bloom filters replace each single bit with a small counter, typically 3–4 bits. Inserting increments the relevant counters; deleting decrements them. A position only reads as “empty” once its counter hits zero, so one item’s removal doesn’t erase another’s. The cost is 3–4× the memory of a plain bit array.
  • Cuckoo filters take a different approach: they store short item fingerprints in a cuckoo hash table instead of setting bits. They support deletion natively, often use less space than a Bloom filter at the same false-positive rate, and have better memory locality since a lookup touches only one or two table buckets instead of k scattered bit positions.

A Minimal Implementation

The core logic fits in about twenty lines. This version uses only the Python standard library, deriving k independent hash positions from SHA-256 with a different integer salt per round:

import hashlib
import math

class BloomFilter:
    def __init__(self, n_items, false_positive_rate):
        self.size = self._optimal_size(n_items, false_positive_rate)
        self.hash_count = self._optimal_hash_count(self.size, n_items)
        self.bits = bytearray(self.size)

    @staticmethod
    def _optimal_size(n, p):
        m = -(n * math.log(p)) / (math.log(2) ** 2)
        return max(1, int(math.ceil(m)))

    @staticmethod
    def _optimal_hash_count(m, n):
        k = (m / n) * math.log(2)
        return max(1, int(round(k)))

    def _indexes(self, item):
        data = item.encode("utf-8")
        for i in range(self.hash_count):
            digest = hashlib.sha256(data + i.to_bytes(4, "big")).digest()
            yield int.from_bytes(digest[:8], "big") % self.size

    def add(self, item):
        for idx in self._indexes(item):
            self.bits[idx] = 1

    def might_contain(self, item):
        return all(self.bits[idx] for idx in self._indexes(item))


bf = BloomFilter(n_items=1000, false_positive_rate=0.01)
bf.add("user:4821")
print(bf.might_contain("user:4821"))  # True: definitely inserted
print(bf.might_contain("user:9999"))  # False, almost always — never a false negative

This stores one byte per bit for clarity; a production implementation would pack eight slots per byte to hit the real 9.6-bits-per-item target. Swapping SHA-256 for a faster non-cryptographic hash (MurmurHash3 is the common production choice) is the usual next optimization once correctness is confirmed.

Trade-offs

  • No enumeration: you can ask “is X in here?” but never get the list of what was actually inserted.
  • No deletion (standard version): requires switching to a counting or cuckoo variant if removal is needed.
  • Sizing matters: a filter sized for n items degrades past that false-positive rate once significantly more items are inserted — it doesn’t fail outright, but accuracy quietly worsens.
  • Hash quality matters: the independence assumption behind the math above breaks down with poorly distributed hash functions, inflating the real false-positive rate above the computed one.

Bottom Line

A Bloom filter isn’t a general-purpose set — it’s a narrow, specific tool for one job: cheaply ruling out “definitely not here” so an expensive lookup only runs when there’s actually a chance of a hit. That narrowness is the whole point. Cassandra uses it to skip SSTables, Postgres uses it to skip heap-row rechecks, Akamai used it to skip caching objects nobody will request again, and Chrome used it to skip network calls for safe URLs. If you’re weighing whether to add one to your own system, the real design question isn’t “should I use a Bloom filter,” it’s what false-positive rate you can tolerate and whether you’ll ever need to delete an entry — the first sets your memory budget via the formulas above, and the second decides whether you need the plain version or a counting/cuckoo variant.

FAQ

Can a Bloom filter ever produce a false negative?
No. If an item was inserted, every bit it set is still set, so a query for it will always find all relevant bits at 1. “Not in the set” is a guaranteed-correct answer.

Can I delete an item from a standard Bloom filter?
Not safely. Clearing a bit could un-set membership for a different item that happens to hash to the same position. Use a counting Bloom filter or a cuckoo filter if deletion is a requirement.

How is this different from just using a hash set?
A hash set stores the actual items (or their full hashes) and is always exact. A Bloom filter stores neither — only a shared bit array — which is why it can be an order of magnitude smaller, at the cost of occasional false positives.

What false-positive rate should I actually pick?
It depends on what a false positive costs downstream. Cassandra’s own defaults — 0.01 for most compaction strategies, 0.1 for leveled compaction — are a reasonable starting range: lower if a false positive means an expensive fallback check, higher if memory is tighter and an occasional unnecessary lookup is cheap.

Sources