Loading content...
Every request arriving at your API gateway carries a destination encoded in its URL path. The seemingly simple act of parsing /api/v2/users/123/orders and routing it to the correct backend service is, at scale, a complex algorithmic and architectural challenge. Path-based routing is the foundational mechanism that transforms a chaotic stream of incoming requests into organized, directed traffic flowing to the appropriate microservices.
In production systems handling millions of requests per second—systems like Netflix, Uber, and Stripe—the path-based routing layer must operate with sub-millisecond latency, support dynamic configuration updates without downtime, handle thousands of routing rules, and gracefully manage path conflicts and ambiguities. This page provides a Principal Engineer's perspective on path-based routing: the theoretical foundations, algorithmic implementations, and battle-tested production patterns.
By the end of this page, you will understand path routing semantics deeply—from URL anatomy to trie-based routing algorithms. You'll master prefix vs. exact matching, path parameter extraction, wildcard patterns, routing priority resolution, and performance optimization techniques used in production gateways.
Before diving into routing strategies, we must establish a precise understanding of URL structure. Every HTTP request contains a Request-URI that follows a well-defined format specified in RFC 3986. The routing layer operates primarily on the path component, though sophisticated gateways may also consider query parameters, fragments, and even complete URIs.
The Anatomy of a URL:
https://api.example.com:443/v2/users/123/orders?status=pending&limit=10#results
└─┬──┘ └───────┬───────┘└┬┘ └─────────┬─────────┘ └──────────┬─────────┘ └──┬───┘
scheme authority port path query string fragment
For routing purposes, the path (/v2/users/123/orders) is the primary decision factor. It consists of:
v2, users, orders) that match literally123) representing resource identifiers or parametersBefore routing, gateways typically normalize paths: removing duplicate slashes (//), resolving dot segments (/. and /..), handling URL encoding (%20 → space), and optionally adding or stripping trailing slashes. Inconsistent normalization is a common source of routing bugs and security vulnerabilities (path traversal attacks).
The Routing Decision Model:
The routing layer implements a function:
route(path: string, method: string) → Backend | null
This function must:
The challenge lies in executing this function millions of times per second while supporting complex pattern matching and dynamic rule updates.
| Segment Type | Syntax Example | Matches | Use Case |
|---|---|---|---|
| Static/Literal | /users | Exactly /users | Fixed API endpoints |
| Path Parameter | /:id or /{id} | Any single segment | Resource identifiers |
| Wildcard (*) | /files/* | Single segment (any) | Catch-all within level |
| Glob (**) | /proxy/** | Zero or more segments | Proxy/passthrough routes |
| Regex | /users/[0-9]+ | Regex pattern match | Constrained parameters |
| Optional | /users/:id? | Segment may be absent | Optional trailing segments |
The two fundamental matching strategies in path-based routing are prefix matching and exact matching. Understanding the semantics and trade-offs of each is essential for designing correct and maintainable routing configurations.
Exact Matching:
Exact matching requires the request path to match the routing rule precisely—no more, no less.
Rule: /api/users (exact)
/api/users → MATCH ✓
/api/users/ → NO MATCH ✗ (trailing slash)
/api/users/123 → NO MATCH ✗ (extra segment)
/api/user → NO MATCH ✗ (different spelling)
Exact matching provides maximum precision but requires explicit rules for every distinct endpoint. It's ideal when you have a finite, well-defined set of endpoints and want to prevent unintended routing.
Prefix Matching:
Prefix matching routes any request whose path starts with the specified prefix.
Rule: /api/v2 (prefix)
/api/v2 → MATCH ✓
/api/v2/ → MATCH ✓
/api/v2/users → MATCH ✓
/api/v2/users/123/orders → MATCH ✓
/api/v1/users → NO MATCH ✗ (different prefix)
Prefix matching is powerful for microservices architectures where an entire path subtree should route to a single service.
path: /api/health (exact)pathPrefix: /api/usersWhether /users and /users/ are treated as the same path is configuration-dependent. RESTful conventions often treat them as different resources (collection vs. collection with sub-path). Your gateway must be explicit about trailing slash handling—either normalize them consistently or treat them as distinct. Ambiguity here causes subtle, hard-to-debug routing issues.
123456789101112131415161718192021222324252627
# Nginx location block matching semantics # Exact match (highest priority)location = /api/health { proxy_pass http://health-service;} # Prefix match (case-sensitive)location /api/v2/users { proxy_pass http://user-service;} # Prefix match with lower prioritylocation /api/v2 { proxy_pass http://default-v2-service;} # Regex match (evaluated in order of appearance)location ~ ^/api/v[0-9]+/orders/[0-9]+$ { proxy_pass http://order-service;} # Precedence order:# 1. Exact match (=)# 2. Preferential prefix (^~)# 3. Regex (~ or ~*)# 4. Prefix match (longest match wins)RESTful APIs encode resource identifiers directly in the path: /users/123, /orders/abc-456, /files/documents/report.pdf. The gateway must extract these dynamic segments and either:
Parameter Syntax Conventions:
Different gateway technologies use varying syntax for path parameters:
| Gateway/Framework | Syntax | Example |
|---|---|---|
| Express.js | :param | /users/:userId |
| Spring/JAX-RS | {param} | /users/{userId} |
| OpenAPI | {param} | /users/{userId} |
| Envoy | :param or regex | /users/:id |
| AWS ALB | Regex groups | /users/(.*) |
Parameter Constraints:
Production systems often constrain parameters to specific patterns:
/users/{userId:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}
This regex constraint ensures userId matches UUID format, preventing invalid requests from reaching the backend.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// Path parameter extraction implementationinterface RouteParams { [key: string]: string;} interface RouteMatch { matched: boolean; params: RouteParams; remainingPath: string;} /** * Parse a route pattern into segments with metadata * Supports: static, :param, :param?, *wildcard, **glob */function parseRoutePattern(pattern: string): RouteSegment[] { const segments: RouteSegment[] = []; const parts = pattern.split('/').filter(Boolean); for (const part of parts) { if (part.startsWith(':')) { const isOptional = part.endsWith('?'); const name = isOptional ? part.slice(1, -1) : part.slice(1); // Check for constraint: :id([0-9]+) const constraintMatch = name.match(/^(\w+)\((.+)\)$/); segments.push({ type: 'param', name: constraintMatch ? constraintMatch[1] : name, optional: isOptional, constraint: constraintMatch ? new RegExp(`^${constraintMatch[2]}$`) : undefined, }); } else if (part === '*') { segments.push({ type: 'wildcard' }); } else if (part === '**') { segments.push({ type: 'glob' }); } else { segments.push({ type: 'static', value: part }); } } return segments;} /** * Match an incoming path against a parsed route pattern */function matchRoute(pathSegments: string[], routeSegments: RouteSegment[]): RouteMatch { const params: RouteParams = {}; let pathIdx = 0; let routeIdx = 0; while (routeIdx < routeSegments.length) { const seg = routeSegments[routeIdx]; if (seg.type === 'static') { if (pathSegments[pathIdx] !== seg.value) { return { matched: false, params: {}, remainingPath: '' }; } pathIdx++; } else if (seg.type === 'param') { if (pathIdx >= pathSegments.length) { if (!seg.optional) { return { matched: false, params: {}, remainingPath: '' }; } } else { const value = pathSegments[pathIdx]; // Validate constraint if present if (seg.constraint && !seg.constraint.test(value)) { return { matched: false, params: {}, remainingPath: '' }; } params[seg.name] = value; pathIdx++; } } else if (seg.type === 'wildcard') { if (pathIdx >= pathSegments.length) { return { matched: false, params: {}, remainingPath: '' }; } pathIdx++; // Consume exactly one segment } else if (seg.type === 'glob') { // Glob captures all remaining segments params['**'] = pathSegments.slice(pathIdx).join('/'); pathIdx = pathSegments.length; } routeIdx++; } // Check if all path segments were consumed const matched = pathIdx === pathSegments.length; const remainingPath = pathSegments.slice(pathIdx).join('/'); return { matched, params, remainingPath };} // Example usage:// Pattern: /users/:userId/orders/:orderId([0-9]+)// Path: /users/abc-123/orders/456// Result: { matched: true, params: { userId: 'abc-123', orderId: '456' } }A common production pattern is for the gateway to extract path parameters and inject them as headers (e.g., X-User-Id: 123) before forwarding to backends. This decouples backends from URL structure—the backend reads headers instead of parsing URLs, enabling path restructuring without backend changes.
When a gateway has thousands of routing rules, the naive approach of iterating through each rule and checking for a match becomes prohibitively slow. Production gateways employ sophisticated data structures—primarily tries (prefix trees) and radix trees—to achieve O(path_length) routing regardless of the number of rules.
The Trie-Based Router:
A trie (pronounced "try") is a tree data structure where:
[root]
│
[api]
/ \
[v1] [v2]
/ \ \
[users] [orders] [users]
│ / \
[:id] [:id] [search]
│ │
[orders] [profile]
Radix Tree Optimization:
A radix tree (compact prefix tree) compresses chains of single-child nodes into single edges, reducing memory usage and improving cache locality:
Trie: [a] → [p] → [i] → [/] → [v] → [1]
Radix Tree: [api/v1]
Envoy proxy uses a route table with a combination of:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
// Production-grade Trie-based Routerinterface TrieNode<T> { children: Map<string, TrieNode<T>>; paramChild?: TrieNode<T>; // :param nodes paramName?: string; wildcardChild?: TrieNode<T>; // * nodes globChild?: TrieNode<T>; // ** nodes handler?: T; priority: number;} class TrieRouter<T> { private root: TrieNode<T> = { children: new Map(), priority: 0, }; /** * Insert a route pattern into the trie * Complexity: O(pattern_segments) */ insert(pattern: string, handler: T, priority: number = 0): void { const segments = pattern.split('/').filter(Boolean); let node = this.root; for (const segment of segments) { if (segment.startsWith(':')) { // Parameter segment if (!node.paramChild) { node.paramChild = { children: new Map(), priority: 0 }; } node.paramChild.paramName = segment.slice(1); node = node.paramChild; } else if (segment === '*') { if (!node.wildcardChild) { node.wildcardChild = { children: new Map(), priority: 0 }; } node = node.wildcardChild; } else if (segment === '**') { if (!node.globChild) { node.globChild = { children: new Map(), priority: 0 }; } node.globChild.handler = handler; node.globChild.priority = priority; return; // Glob terminates the pattern } else { // Static segment if (!node.children.has(segment)) { node.children.set(segment, { children: new Map(), priority: 0 }); } node = node.children.get(segment)!; } } node.handler = handler; node.priority = priority; } /** * Find the best matching route for a path * Complexity: O(path_segments) average case */ find(path: string): { handler: T; params: Record<string, string> } | null { const segments = path.split('/').filter(Boolean); const matches: Array<{ handler: T; params: Record<string, string>; priority: number }> = []; this.findRecursive(this.root, segments, 0, {}, matches); if (matches.length === 0) return null; // Return highest priority match (static > param > wildcard > glob) matches.sort((a, b) => b.priority - a.priority); return { handler: matches[0].handler, params: matches[0].params }; } private findRecursive( node: TrieNode<T>, segments: string[], depth: number, params: Record<string, string>, matches: Array<{ handler: T; params: Record<string, string>; priority: number }> ): void { // Check for complete match if (depth === segments.length) { if (node.handler !== undefined) { matches.push({ handler: node.handler, params: { ...params }, priority: node.priority + 1000 // Exact match bonus }); } return; } const segment = segments[depth]; // Priority 1: Static match (highest priority) if (node.children.has(segment)) { this.findRecursive( node.children.get(segment)!, segments, depth + 1, params, matches ); } // Priority 2: Parameter match if (node.paramChild) { const newParams = { ...params, [node.paramChild.paramName!]: segment }; this.findRecursive(node.paramChild, segments, depth + 1, newParams, matches); } // Priority 3: Wildcard match if (node.wildcardChild) { this.findRecursive(node.wildcardChild, segments, depth + 1, params, matches); } // Priority 4: Glob match (captures remaining path) if (node.globChild && node.globChild.handler !== undefined) { const globPath = segments.slice(depth).join('/'); matches.push({ handler: node.globChild.handler, params: { ...params, '**': globPath }, priority: node.globChild.priority }); } }} // Usage exampleconst router = new TrieRouter<string>();router.insert('/api/v1/users', 'user-list', 100);router.insert('/api/v1/users/:userId', 'user-detail', 90);router.insert('/api/v1/users/:userId/orders', 'user-orders', 80);router.insert('/static/**', 'static-files', 10); console.log(router.find('/api/v1/users'));// { handler: 'user-list', params: {} } console.log(router.find('/api/v1/users/123'));// { handler: 'user-detail', params: { userId: '123' } } console.log(router.find('/static/images/logo.png'));// { handler: 'static-files', params: { '**': 'images/logo.png' } }| Algorithm | Time Complexity (Match) | Space Complexity | Regex Support | Use in Production |
|---|---|---|---|---|
| Linear Scan | O(n × path_len) | O(n) | Yes | Small rule sets only |
| Hash Map (exact) | O(path_len) | O(n) | No | Health checks, static routes |
| Trie | O(path_len) | O(n × path_len) | Limited | Most gateways |
| Radix Tree | O(path_len) | O(n) | Limited | Envoy, high-perf systems |
| Hybrid (Radix + Regex) | O(path_len + m) | O(n) | Yes | Production gateways |
When multiple routing rules match an incoming request, the gateway must deterministically select one. The priority resolution strategy is critical—ambiguous or incorrect resolution causes misdirected traffic, often silently.
The Conflict Scenario:
Consider these two rules:
Rule A: /api/users/:id → user-service
Rule B: /api/users/search → search-service
Request /api/users/search matches both rules:
:id = "search"Which wins? The answer depends on the gateway's resolution strategy.
Common Resolution Strategies:
/users/search beats /users/:id beats /users/* beats /**.Production Best Practice: Explicit Priority with Logging
Combine specificity-based defaults with explicit priority overrides and conflict logging:
routes:
# Explicit high priority for specific endpoints
- path: /api/users/search
priority: 100
backend: search-service
# Lower priority for parameterized routes
- path: /api/users/:id
priority: 50
backend: user-service
# Default catch-all with lowest priority
- path: /api/**
priority: 1
backend: legacy-api
Log all ambiguous matches (where multiple rules have equal priority) to detect configuration issues before they cause incidents.
One of the most insidious production issues is silent misrouting—where requests go to the wrong service but still receive a "valid" response. For example, /users/status hitting a user detail endpoint with id="status" and returning a 404. Monitor routing decisions with request tracing and validate routing tables in CI/CD.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// Priority calculation based on pattern specificityinterface RouteRule { pattern: string; backend: string; explicitPriority?: number;} function calculatePriority(pattern: string): number { const segments = pattern.split('/').filter(Boolean); let priority = 0; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const positionWeight = (segments.length - i) * 10; // Earlier segments matter more if (segment === '**') { // Glob: lowest priority priority += positionWeight * 1; } else if (segment === '*') { // Wildcard: low priority priority += positionWeight * 2; } else if (segment.startsWith(':')) { // Parameter: medium priority priority += positionWeight * 5; } else { // Static: highest priority priority += positionWeight * 10; } } // Longer patterns generally more specific priority += segments.length * 5; return priority;} function resolveConflict(rules: RouteRule[], path: string): RouteRule | null { const matches = rules.filter(rule => matchPattern(rule.pattern, path)); if (matches.length === 0) return null; if (matches.length === 1) return matches[0]; // Sort by explicit priority first, then calculated priority matches.sort((a, b) => { const aPriority = a.explicitPriority ?? calculatePriority(a.pattern); const bPriority = b.explicitPriority ?? calculatePriority(b.pattern); return bPriority - aPriority; }); // Log if ambiguous (top two have same priority) const top = matches[0]; const second = matches[1]; const topPriority = top.explicitPriority ?? calculatePriority(top.pattern); const secondPriority = second.explicitPriority ?? calculatePriority(second.pattern); if (topPriority === secondPriority) { console.warn(`Ambiguous routing for ${path}:`, `${top.pattern} and ${second.pattern} have equal priority (${topPriority})`); } return top;} // Example:const rules: RouteRule[] = [ { pattern: '/api/users/:id', backend: 'user-service' }, { pattern: '/api/users/search', backend: 'search-service' }, { pattern: '/api/**', backend: 'legacy-api' },]; const match = resolveConflict(rules, '/api/users/search');// Returns: { pattern: '/api/users/search', backend: 'search-service' }// Because static 'search' beats parameter ':id'Routing is only half the story. After selecting a backend, the gateway often needs to transform the request path before forwarding. Common transformations include:
Path Stripping Example:
Incoming: /api/v2/users/123
Rule: /api/v2/users → user-service (strip prefix)
Forwarded: /123 (prefix stripped)
This pattern enables the gateway to "own" the API namespace (/api/v2/users) while backends receive root-relative paths.
Path Prepending Example:
Incoming: /users/123
Rule: /users → legacy-api (prepend /v1/accounts)
Forwarded: /v1/accounts/users/123
This enables API facade patterns where the public API differs from internal service paths.
1234567891011121314151617181920212223242526272829303132
# Envoy route configuration with path rewritingroutes: - match: prefix: "/api/v2/users" route: cluster: user-service # Strip /api/v2/users prefix, keep the rest prefix_rewrite: "/" - match: prefix: "/public/users" route: cluster: user-service # Rewrite /public/users/123 → /internal/accounts/123 regex_rewrite: pattern: google_re2: {} regex: "^/public/users/(.*)" substitution: "/internal/accounts/\1" - match: safe_regex: google_re2: {} regex: "^/api/v([0-9]+)/(.*)$" route: cluster: versioned-api # Capture version and path, restructure regex_rewrite: pattern: google_re2: {} regex: "^/api/v([0-9]+)/(.*)$" substitution: "/\2?version=\1" # /api/v2/users → /users?version=2In non-production environments, inject a debug header showing the original and rewritten paths: X-Original-Path: /api/v2/users/123 and X-Rewritten-Path: /123. This dramatically simplifies debugging routing issues.
Path-based routing in production requires addressing several operational concerns that don't surface in development:
1. Configuration Hot-Reloading
Routing rules change frequently. The gateway must support live configuration updates without dropping connections or causing traffic disruption. Production gateways use:
2. Routing Table Size Limits
Large routing tables impact:
Most gateways support 10,000+ routes, but regex-heavy configurations may hit performance cliffs.
3. Security Implications
/../ and encoded variants are normalized before routingLocal gateway configurations often differ from production. Path matching behavior, case sensitivity, and trailing slash handling can vary between gateway versions and configurations. Always validate routing changes in a staging environment that mirrors production exactly.
Path-based routing is the foundational mechanism that directs API traffic to appropriate backend services. Mastering it requires understanding URL anatomy, matching semantics, algorithmic implementations, and production operational concerns.
You now have a deep understanding of path-based routing—from URL fundamentals to production-grade trie implementations. The next page covers header-based routing, which adds another dimension to routing decisions by considering HTTP headers, enabling sophisticated traffic management patterns.