DEV Community

Mohsen Zainalpour
Mohsen Zainalpour

Posted on

Packet classification, for mock servers

Every HTTP mock server does the same three things. It accepts a request, decides which of your configured stubs that request matches, and writes the corresponding response.

The first and third are solved problems. The second is where the engineering is, and it is the one nobody thinks about, because at the size where you first meet it, it is free.

Here is the part that is not free: its cost is a function of your config file, and your config file only ever grows.

The shape of the problem

The obvious implementation is the one you would write on a whiteboard.

for stub in stubs:
    if all(p.matches(request) for p in stub.predicates):
        return stub.response
return default_response
Enter fullscreen mode Exit fullscreen mode

This is correct, it is obviously correct, and it is what you should write first. Its cost is O(stubs) per request, with a constant set by how expensive your predicates are. At twelve stubs of equals predicates it is a rounding error against the cost of reading the socket.

Now run it for four years. Nobody plans to have three hundred stubs. You have twelve. An integration lands and brings eight. Someone mocks the error path, then the timeout path, then the malformed response. A contractor adds forty for a migration that shipped in 2023 and nobody deleted them, because deleting a stub is how you discover which test depended on it.

And the predicates get more expensive as they get more specific. An equals on a path is a string comparison. A matches is a regex. A jsonpath or xpath predicate parses the body — and if a body-inspecting predicate is in the scan, you may be parsing the same request body once per candidate stub, per request.

I measured the shape against the two engines that scan. Both tables below hold engine, host and load fixed and vary only how much matching work a request implies — but they do it in slightly different ways, and that is worth naming rather than smoothing over.

Mountebank 2.9.1, Apple M4 laptop, oha at 50 keep-alive connections, 20s per scenario after warmup, median of 3 runs, measured 2026-07-20. This is the strict version of the question: one 310-stub imposter throughout, moving only from the stub that matches first to the stub that matches last. Corpus and predicate shape are held constant, so position in the scan is the only variable.

first match last match
Mountebank 8,546 RPS 1,344 RPS
Rift 211,378 RPS 209,523 RPS

WireMock 3.9.1, Xeon Platinum 8573C at 16 vCPU, oha at 256 keep-alive connections, measured 2026-07-27, with WireMock's Jetty pool pinned to 256 so its 10-thread default was not doing the losing for it. This is the looser version: a one-stub imposter against a 310-stub deep path match, so corpus size and path depth move together and the fall is both effects at once.

1 stub 310-stub deep match
WireMock 83,048 RPS 24,264 RPS
Rift 334,025 RPS 326,779 RPS

p99 latency on that second column: 31.6 ms for WireMock, 2.5 ms for Rift.

Do not read across the two tables. Different hosts, different concurrency levels, different days — Rift itself reads 211k on one and 334k on the other, which tells you how much of any cross-table comparison would be hardware rather than engine. Each table is sound read downwards, and nothing else.

Two engines lose 71% and 84% of their throughput between the first stub and the last. One loses under 1% and 2%. The multiple is the least interesting thing on either table. The slope is the argument.

It is packet classification, and it has been solved since the 1990s

Here is the reframe that made this tractable, and I want to be exact about the fact that I did not invent any of it.

"Which of these N rules matches this packet?" is the central question of network firewalls and routers. A packet arrives; some hundreds or thousands of ACL rules each specify constraints over source address, destination address, protocol, port ranges; exactly one — the first match, by rule order — decides what happens to it. This is called packet classification, it has a literature going back to the mid 1990s, and nobody in that field has evaluated rules in a loop for a very long time.

Now read the mock-server problem again. A request arrives; some hundreds of stubs each specify constraints over method, path, headers, query, body; exactly one — the first match, by declaration order — decides the response.

It is the same problem. Different attribute names, identical shape: multi-dimensional first-match-wins classification over a rule set that is large, mostly static, and known in advance.

The technique Rift uses is the Lucent bit-vector algorithm (Lakshman and Stiliadis, 1998). The idea is almost embarrassingly simple once stated:

  1. Give every rule an id equal to its position in the ordered rule list.
  2. For each dimension independently, precompute which rules that dimension can rule out for any given input.
  3. At query time, ask each dimension for a bitset over rule ids — the rules it cannot eliminate.
  4. Intersect the bitsets. Walk the surviving bits in ascending order.

That is the whole thing. Every dimension prunes on its own, the intersection is the candidate set, and the first surviving bit is the answer.

The freebie that made me trust it

Step 1 above is doing more work than it looks like it is doing.

A stub's id is its position in the declaration-ordered stub vector. A candidate set is a dense bitset over those ids. Iterating a bitset yields set bits in ascending numeric order.

So ascending bit order is Mountebank's first-match-wins order. Not "is equivalent to." Is. The ordering semantics are not implemented anywhere; they fall out of the representation. There is no sort, no priority comparison, no code path where a refactor could get precedence subtly wrong, because precedence is not a decision the matcher makes.

That is the property that convinced me this was the right structure rather than a clever one. In a compatibility-constrained engine, the semantics you cannot accidentally break are worth more than the ones you have tests for.

One honest limit on that guarantee: it fixes the order of the candidates, not their membership. Which brings up the obvious question.

How do you know you did not drop a match?

You constrain the index in one direction only.

Every dimension's bitset is matched_bits | always_bits — the stubs whose constraint the request satisfies, plus every stub that either does not constrain this attribute at all or constrains it in a shape this dimension cannot index. The invariant:

A dimension may only ever exclude a stub it can prove cannot match.

So the candidate set is a strict over-approximation: a superset of the true matches. Full predicate evaluation still runs on every survivor, using the unchanged Mountebank semantics, which remain the only source of truth about whether a stub matches. The index never decides that a stub does match. It only ever decides which stubs are not worth asking about.

That asymmetry is what makes the design safe to extend. A dimension that keeps too many stubs costs performance and nothing else, so widening one later is a pure optimisation and never a semantics question. A dimension that wrongly excludes a stub makes it silently stop matching — which is the worst failure this system can have, and the reason a differential test (differential_index_matches_linear_oracle) runs the index against a linear oracle and fails on any under-approximation rather than trusting the argument I just made.

This is a rich enough topic that it deserves its own piece, and it will get one. For now the thing to take is the shape: a prefilter that is allowed to be wrong in exactly one direction is a different, much more tractable engineering problem than a matcher that has to be right.

Six dimensions, and one of them in detail

Rift's index is six dimensions over four request attributes — the path carries three of them, one each for exact, literal and regex constraints.

# Dimension Indexes Structure
1 Method equals on method eight fixed slots — seven common verbs plus an "other" bucket
2 Path, exact equals on path hash map on the case-folded path
3 Path literals startsWith / contains / endsWith Aho-Corasick automaton over every anchor
4 Path regexes matches on path multi-pattern automaton, all matching pattern ids in one search
5 Body, whole deepEquals on a JSON body structural hash of the expected body
6 Body, field equals on body fields a quamina field automaton

Rather than tour all six, take the third one, because it is the one where the cross-domain borrowing pays most visibly.

Path literal predicates are the awkward middle of the space. equals hashes. matches needs a regex engine. But startsWith("/api/v2"), contains("/internal/") and endsWith(".json") are neither — they are substring tests, and the naive index for them is a bucket per anchor string that you walk one at a time. With 200 literal anchors, answering "which of these appear in this path" means 200 substring searches over the same short string.

Aho-Corasick answers all of them in one pass. You build a trie of every anchor, add failure links so that a mismatch resumes at the longest proper suffix that is still a viable prefix, and then run the path through the resulting automaton exactly once. Every anchor that occurs anywhere in the path reports itself, in time proportional to the length of the path plus the number of matches — not to the number of anchors. Anchoring for startsWith and endsWith is a position check on the reported match, which is free once you have the match.

This is a 1975 algorithm. It is what fgrep was built on. The reason it belongs here is not that it is fast in the abstract but that it changes what the cost is a function of: from "how many literal predicates have you written" to "how long is the path you are matching." Those are very different curves, and only one of them is under your users' control.

Dimension 4 is the same move against regexes. Rift's own slow path used to be regex predicates, because you cannot hash-dispatch a regex: at the 100th pattern it managed roughly 54k RPS. Replacing the per-pattern loop with a single multi-pattern automaton — one overlapping search that reports every matching pattern id — took that to about 207k, in line with every other predicate type. That is not a micro-optimisation. It is a change of complexity class, from one search per pattern to one search.

The order the dimensions run in

They fold cheapest-first, and the fold short-circuits the moment the candidate set is empty. A request no stub can match usually stops after the first dimension or two, which is the case that matters most in practice — misses are common and they should be cheap.

The two body dimensions run last, because they are the only ones that touch the request body. By the time they run, the method and path dimensions have already emptied the accumulator in most cases, so the expensive parse is skipped rather than optimised. When it does run, the body is parsed as JSON once per request and shared across both dimensions, rather than each predicate reaching for the raw bytes independently.

A dimension that indexes nothing is skipped entirely rather than paying a full-width copy and intersect to learn nothing. This matters more than it sounds: it means an imposter the index cannot help degrades to a plain scan instead of paying for an index it is not using.

Why the bitsets are hand-rolled

The obvious question from anyone who has reached for roaring or fixedbitset: why write your own?

Because the operation set is tiny. Intersect, union, iterate ascending. That is all a candidate set ever needs to do, and compressed bitmaps buy their compression back in branchy decode paths that a dense word vector does not have.

The sizes involved make the decision easy. 4,096 stubs is 512 bytes — one bitset fits in L1 several times over, word-wise AND autovectorises, and the whole intersection is a handful of cache resident SIMD instructions. Roaring's advantage arrives when your universe is sparse and enormous. A stub corpus is neither.

Dimensions are concrete struct fields rather than Box<dyn Dimension>, so the fold dispatches statically. Matching is not allocation-free, and I would rather say so than let you find out: the accumulator and a per-dimension scratch bitset are allocated per request, and a path containing uppercase bytes allocates a folded copy.

What this does not buy you

The section above is the optimistic half. Here is the other one.

The index helps only when stubs are distinguishable on an indexed attribute. An imposter where every stub is a body regex indexes on nothing, falls back to the scan, and is exactly as linear as Mountebank — with a better constant, and that is all.

Full evaluation always runs. There is nothing for a prefilter to save on a one- or two-stub imposter, which is why the simple-stub scenario is among the lowest multiples in every table I publish — 4.0x against WireMock, 24x against Mountebank. The large multiples come from scenarios where the index removes work, not from the implementation stack alone. Where something other than matching dominates the request, such as response templating, the multiple is lower still.

Indexing is not the same as being fast, and one of the comparisons proves it. Microcks is also indexed rather than scanning, and it is also flat by stub position: 6,457 → 6,447 → 6,420 RPS across the first, middle and last of the same 310 operations — a 0.6% spread, on the identical shape of test as the two tables above. That is Microcks 1.14.0 on Temurin 21, AMD EPYC 7763 at 16 vCPU, oha at 256 keep-alive connections, measured 2026-07-30.

So the gap between Rift and Microcks is almost entirely per-request cost rather than scaling, and it would be dishonest to claim the flatness wedge against it — the argument in this article simply does not apply to Microcks. Microcks is a CNCF project that does a great deal Rift does not: it is API-contract first, it imports OpenAPI and AsyncAPI directly, and it covers async protocols Rift has no answer
for at all. Two engines can share this architecture and differ by a lot for entirely different
reasons.

None of this is a throughput claim about your workload. It is a claim about why the curve is flat. The absolute numbers depend on your hardware, your predicates and your response sizes.

Where else this shape shows up

The reason I think this is worth reading even if you will never install my project: a per-request loop over a config collection is a slow outage that a team contributes to one commit at a time and
never attributes correctly. The person who adds the entry that tips it over pays nothing. The cost lands on whoever profiles the service six months later, and by then the growth looks like weather.

Mock servers are one instance. Feature-flag evaluators, authorisation policy engines, routing tables, webhook dispatchers, and validation middleware are all the same shape: N rules, one input, first match wins, N grows monotonically because deleting an entry requires knowing who depended on it.

If one of yours is on that list, the useful question is not "is it fast" but "what is its cost a function of, and who controls that number." Packet classification is forty years of answers to exactly that question, and almost none of it has made it out of networking.

What this is

Rift is a Mountebank-compatible HTTP/HTTPS mock server written in Rust. Compatible meaning the same REST admin API, so existing Mountebank clients work unchanged, and the same imposters.json, loaded without edits. It is beta, at v0.17.

That compatibility constraint came before any of the performance work, and it is the reason the matching semantics are Mountebank's rather than mine. Nobody switches mock servers out of dissatisfaction; they switch when switching is free. If adoption requires rewriting your stubs, the benchmark does not matter.

Rift exists because Mountebank designed a genuinely good API — one worth reimplementing rather than replacing. Its throughput ceiling is a property of Node's single-threaded request handling, which is a runtime cost, not a design flaw, and the design is the part I kept.

The benchmark harness is in the repo under tests/benchmark, and it runs the other engines too, not only mine. If you think a comparison here is unfair, that is the fastest way to demonstrate it — and a correction that narrows a gap I have claimed is the most useful thing anyone could send me.

Before that, though: go and count the stubs in your own config file. In my experience the number is higher than anyone on the team would guess, and nobody has ever measured what it costs.


Repo: https://github.com/achird-labs/rift
Matching architecture and full benchmark method: https://achird-labs.github.io/rift/performance/

Top comments (0)