Skip to content

feat(http/unstable): add radix tree router; keep linear scan as routeLinear#7075

Open
esroyo wants to merge 1 commit intodenoland:mainfrom
esroyo:unstable-route-radix
Open

feat(http/unstable): add radix tree router; keep linear scan as routeLinear#7075
esroyo wants to merge 1 commit intodenoland:mainfrom
esroyo:unstable-route-radix

Conversation

@esroyo
Copy link
Copy Markdown

@esroyo esroyo commented Mar 31, 2026

Add routeRadix(), a radix tree router that provides O(segments) dispatch for static, parametric, and wildcard routes. Routes with complex URLPattern syntax (regex constraints, optional groups, inline wildcards, modifier suffixes) fall back to linear matching while preserving insertion order.

  • routeRadix: radix tree with fallback to linear for complex patterns
  • routeLinear: the original linear scan, extracted as its own export
  • route: re-exported alias for routeRadix (backward compatible)

The radix router matches routeLinear semantics exactly; insertion order is always respected, even when static and parametric routes overlap at the same tree depth.

Benchmarks show 1.5–9x improvement on static/parametric/wildcard routes, with negligible overhead on complex fallback patterns.


---
config:
    themeVariables:
        xyChart:
            plotColorPalette: "#e05c5c, #4a90d9"
---
xychart-beta
    title "route: 🔴 linear vs 🔵 radix - Avg latency (µs)"
    x-axis [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
    y-axis "Avg latency (µs)" 0 --> 7

    line [0.91, 2.04, 6.1, 1.63, 5.54, 1.49, 2.84, 1.08, 3.15, 0.9, 1.45, 1.98]
    line [1.08, 1.38, 1.39, 0.62, 0.61, 1.55, 1.66, 0.91, 1.73, 1.46, 1.62, 2.1]
Loading
# Group 🔴 linear (µs) 🔵 radix (µs)
1 static route — first in small table 0.91 1.08
2 static route — last in small table 2.04 1.38
3 static route — last in large table 6.1 1.39
4 static route — miss (small table) 1.63 0.62
5 static route — miss (large table) 5.54 0.61
6 parametric route — single param 1.49 1.55
7 parametric route — two params (nested) 2.84 1.66
8 parametric route — method mismatch 1.08 0.91
9 wildcard route 3.15 1.73
10 complex — regex constraint 0.9 1.46
11 complex — optional group 1.45 1.62
12 complex — inline wildcard with suffix 1.98 2.1

    CPU | AMD Ryzen 7 PRO 6850U with Radeon Graphics
Runtime | Deno 2.7.9 (x86_64-unknown-linux-gnu)

file:///../denoland/std/http/unstable_route_bench.ts

| benchmark   | time/iter (avg) |        iter/s |      (min … max)      |      p75 |      p99 |     p995 |
| ----------- | --------------- | ------------- | --------------------- | -------- | -------- | -------- |

group static route — first in small table
| linear      |        921.1 ns |     1,086,000 | (869.7 ns …   1.4 µs) | 921.5 ns |   1.4 µs |   1.4 µs |
| radix       |          1.1 µs |       885,100 | (  1.1 µs …   1.3 µs) |   1.1 µs |   1.3 µs |   1.3 µs |

summary
  linear
     1.23x faster than radix

group static route — last in small table
| linear      |          2.0 µs |       494,200 | (  2.0 µs …   2.6 µs) |   2.0 µs |   2.6 µs |   2.6 µs |
| radix       |          1.3 µs |       751,200 | (  1.3 µs …   1.7 µs) |   1.3 µs |   1.7 µs |   1.7 µs |

summary
  linear
     1.52x slower than radix

group static route — last in large table
| linear      |          5.9 µs |       170,100 | (  5.8 µs …   6.1 µs) |   5.9 µs |   6.1 µs |   6.1 µs |
| radix       |          1.3 µs |       750,600 | (  1.3 µs …   1.6 µs) |   1.3 µs |   1.6 µs |   1.6 µs |

summary
  linear
     4.41x slower than radix

group static route — miss (small table)
| linear      |          1.7 µs |       596,400 | (  1.6 µs …   2.1 µs) |   1.7 µs |   2.1 µs |   2.1 µs |
| radix       |        622.6 ns |     1,606,000 | (600.0 ns … 702.4 ns) | 621.7 ns | 702.4 ns | 702.4 ns |

summary
  linear
     2.69x slower than radix

group static route — miss (large table)
| linear      |          5.6 µs |       179,100 | (  5.4 µs …   5.9 µs) |   5.6 µs |   5.9 µs |   5.9 µs |
| radix       |        606.1 ns |     1,650,000 | (594.0 ns … 641.1 ns) | 610.5 ns | 641.1 ns | 641.1 ns |

summary
  linear
     9.21x slower than radix

group parametric route — single param
| linear      |          1.5 µs |       645,600 | (  1.5 µs …   1.7 µs) |   1.6 µs |   1.7 µs |   1.7 µs |
| radix       |          1.5 µs |       649,500 | (  1.5 µs …   1.9 µs) |   1.5 µs |   1.9 µs |   1.9 µs |

summary
  linear
     1.01x slower than radix

group parametric route — two params (nested)
| linear      |          2.8 µs |       360,900 | (  2.7 µs …   2.9 µs) |   2.8 µs |   2.9 µs |   2.9 µs |
| radix       |          1.6 µs |       614,300 | (  1.6 µs …   1.7 µs) |   1.7 µs |   1.7 µs |   1.7 µs |

summary
  linear
     1.70x slower than radix

group parametric route — method mismatch
| linear      |          1.1 µs |       924,900 | (  1.0 µs …   1.4 µs) |   1.1 µs |   1.4 µs |   1.4 µs |
| radix       |        929.0 ns |     1,076,000 | (900.7 ns … 980.0 ns) | 933.5 ns | 980.0 ns | 980.0 ns |

summary
  linear
     1.16x slower than radix

group wildcard route
| linear      |          3.2 µs |       314,400 | (  3.1 µs …   3.6 µs) |   3.2 µs |   3.6 µs |   3.6 µs |
| radix       |          1.7 µs |       600,900 | (  1.6 µs …   2.0 µs) |   1.7 µs |   2.0 µs |   2.0 µs |

summary
  linear
     1.91x slower than radix

group complex — regex constraint
| linear      |        947.0 ns |     1,056,000 | (908.1 ns …   1.3 µs) | 947.5 ns |   1.3 µs |   1.3 µs |
| radix       |          1.5 µs |       673,300 | (  1.4 µs …   1.7 µs) |   1.5 µs |   1.7 µs |   1.7 µs |

summary
  linear
     1.57x faster than radix

group complex — optional group
| linear      |          1.5 µs |       680,000 | (  1.4 µs …   1.7 µs) |   1.5 µs |   1.7 µs |   1.7 µs |
| radix       |          1.6 µs |       625,800 | (  1.6 µs …   1.8 µs) |   1.6 µs |   1.8 µs |   1.8 µs |

summary
  linear
     1.09x faster than radix

group complex — inline wildcard with suffix
| linear      |          1.9 µs |       529,200 | (  1.8 µs …   2.1 µs) |   1.9 µs |   2.1 µs |   2.1 µs |
| radix       |          2.0 µs |       490,400 | (  2.0 µs …   2.2 µs) |   2.0 µs |   2.2 µs |   2.2 µs |

summary
  linear
     1.08x faster than radix

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 31, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added the http label Mar 31, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 31, 2026

Codecov Report

❌ Patch coverage is 92.25352% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.40%. Comparing base (1cd63ca) to head (d522ffa).

Files with missing lines Patch % Lines
http/unstable_route.ts 92.25% 6 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7075      +/-   ##
==========================================
- Coverage   94.41%   94.40%   -0.01%     
==========================================
  Files         630      630              
  Lines       50490    50617     +127     
  Branches     8949     8988      +39     
==========================================
+ Hits        47669    47786     +117     
- Misses       2249     2255       +6     
- Partials      572      576       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…Linear

Add routeRadix(), a radix tree router that provides O(segments) dispatch
for static, parametric, and wildcard routes. Routes with complex URLPattern
syntax (regex constraints, optional groups, inline wildcards, modifier
suffixes) fall back to linear matching while preserving insertion order.

- routeRadix: radix tree with fallback to linear for complex patterns
- routeLinear: the original linear scan, extracted as its own export
- route: re-exported alias for routeRadix (backward compatible)

The radix router matches routeLinear semantics exactly — insertion order
is always respected, even when static and parametric routes overlap at
the same tree depth.

Benchmarks show 1.5–9x improvement on static/parametric/wildcard routes,
with negligible overhead on complex fallback patterns.
@esroyo esroyo force-pushed the unstable-route-radix branch from 786873d to d522ffa Compare March 31, 2026 20:19
}
return routeMethod.toUpperCase() === requestMethod;
}
export { routeRadix as route };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This silently changes the semantics of route() for existing callers — they go from a simple linear scan to a radix tree with fallback. While behavior should be equivalent, if there's any divergence it becomes a regression for current users.

Would it be safer to keep route as the linear implementation (preserving current behavior) and offer routeRadix as an explicit opt-in? That gives the radix path time to mature without risking surprises for existing consumers.

const segments = parseSegments(pathname);
const radixCandidates: IndexedRoute[] = [];
collectCandidates(root, segments, 0, radixCandidates);
radixCandidates.sort((a, b) => a.index - b.index);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every request allocates radixCandidates and sorts it, then the merge with fallbackRoutes may allocate yet another array. For the common happy path of a single candidate, the sort is wasted work.

This partly explains the ~1.2x slowdown for "static route — first in small table" in the benchmarks. Consider skipping the sort when radixCandidates.length <= 1, and/or pre-sorting fallback routes at build time so the merge can be avoided when radixCandidates is empty.

* Extract pathname from a URL string without allocating a URL object.
* Handles both `http://host/path?query` and `http://host/path` forms.
*/
function parsePathname(url: string): string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This custom parser assumes the URL always has an authority (//). It works for http(s):// URLs from Request objects, but would break for schemes like file:///path (double // is part of the scheme, not authority) or URLs with userinfo containing /.

Since this is an internal function only called with request.url, it's safe in practice, but a short comment documenting the assumption ("only handles http(s):// URLs from Request objects") would prevent future misuse.


function insert(r: Route): void {
const indexed: IndexedRoute = { route: r, index: insertionCounter++ };
const segments = parseSegments(r.pattern.pathname);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The radix tree indexes routes solely by pathname segments, but URLPattern can also constrain hostname, search, protocol, etc. Routes like new URLPattern({ hostname: "api.example.com", pathname: "/data" }) get inserted in the tree under /data, so a request to /data on a different hostname will produce a tree candidate that pattern.exec() then rejects.

This is correct (the tree is a pre-filter, pattern.exec is authoritative), but it means the tree provides no pruning benefit for multi-component patterns — and may produce more candidates than a linear scan would test. Worth documenting this trade-off.


/**
* Route configuration for {@linkcode route}.
* Route configuration for {@linkcode routeRadix}.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Route is shared by both routeRadix and routeLinear, so linking it exclusively to routeRadix is misleading. Consider linking to both, or reverting to the more general route.

function isComplexSegment(segment: string): boolean {
if (segment.includes("{") || segment.includes("(")) return true;
if (segment.includes("*") && segment !== "*") return true;
if (segment.endsWith("?") || segment.endsWith("+")) return true;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The endsWith("+") check correctly catches :param+ (one-or-more repeating), but it would also flag a hypothetical literal segment like foo+bar as complex. In practice URLPattern pathnames won't have unencoded + in static segments, so this is fine — but it's worth noting in the doc comment that this heuristic assumes well-formed URLPattern syntax.

Also: does this catch all modifier suffixes? URLPattern also supports * as a modifier (e.g., :id*), but that's already handled by the includes("*") check above. Looks complete to me, just want to confirm that was intentional.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants