Custom Cache
IRouter implementations can carry an optional ICache for memoizing lookup(path) results. Caching is opt-in: by default, every lookup runs the router's full match logic. To enable memoization, pass an ICache via BaseRouterOptions.cache.
The shipped default implementation is LruCache, a bounded LRU built on quick-lru. For TTL or size-based eviction, write a small adapter around lru-cache (or any other cache library) that satisfies the ICache contract.
Default behavior — no cache
import { App, TrieRouter, defineCoreHandler } from 'routup';
// No cache — every request runs the trie walk.
const app = new App({ router: new TrieRouter() });
app.get('/users/:id', defineCoreHandler((event) => `user-${event.params.id}`));For most workloads (small route counts, modest throughput, varied paths) this is the right default — there's no per-router LRU allocation, no extra dependency in your bundle, and no chance of surprise behavior in tests when routes mutate.
Enabling the LRU cache
import { App, TrieRouter, LruCache } from 'routup';
const app = new App({
router: new TrieRouter({ cache: new LruCache() }), // max=1024 by default
});Repeated requests to the same path then skip the trie walk after the first hit. The cache is invalidated whenever IRouter.add is called (i.e. app.use/.get/.post/etc.) so newly added routes are always considered.
Custom LruCache size
import { App, TrieRouter, LruCache } from 'routup';
const app = new App({
router: new TrieRouter({
cache: new LruCache({ maxSize: 4096 }),
}),
});maxSize is the bound on entries (the underlying quick-lru keeps up to 2 * maxSize due to its two-bucket rotation, but eventually evicts everything older).
The ICache<V> contract
export interface ICache<V> {
get(key: string): V | undefined;
set(key: string, value: V): void;
delete(key: string): void;
clear(): void;
}For router lookup caching, V is readonly RouteMatch<T>[] (the type returned by IRouter.lookup).
Contract notes
get(key)returnsundefinedfor absent keys. Implementations cannot storeundefinedas a value; the absent-key sentinel and the no-cache-entry case are conflated, by design. Other falsy values (null,0,'',false) are valid cached payloads.clear()drops every entry. Called by the router on everyadd(). Conservative — future plans may switch to per-path invalidation.
Wrapping lru-cache for TTL
import { LRUCache } from 'lru-cache';
import { App, TrieRouter } from 'routup';
import type { ICache } from 'routup';
class TtlCache<V extends {}> implements ICache<V> {
private inner = new LRUCache<string, V>({ max: 4096, ttl: 30_000 });
get(key: string) { return this.inner.get(key); }
set(key: string, v: V) { this.inner.set(key, v); }
delete(key: string) { this.inner.delete(key); }
clear() { this.inner.clear(); }
}
const app = new App({ router: new TrieRouter({ cache: new TtlCache() }) });Same pattern works for any backend — wrap whatever cache library you already use into a thin ICache adapter.
When to enable caching
Enable when:
- Lookup is non-trivial (large route count, parametric/regex paths) and
- The same paths are requested repeatedly (e.g. an API where most traffic hits a handful of canonical routes)
Skip caching when:
- Route count is small and the linear/trie walk is already cheap
- Path cardinality is high and unbounded (cache hits would be rare; bound is forced)
- Determinism in tests matters more than per-lookup speed
Benchmark before enabling — the default-off shape exists because the gain is workload-dependent and often within noise.