Query Cache
Cache RPC results by method path and input. Dedupe in-flight calls, invalidate on mutations, or wrap any async function.
Tacho ships a result cache so reads don’t refetch what hasn’t changed. It dedupes identical in-flight calls, keeps results fresh for a window you choose, and never caches failures. There are two surfaces over the same engine:
client.query()/client.cache— cached calls through an HTTP client, keyed by method path.query(fn)— a wrapper that gives any async function the same semantics.
Cached client calls
Every HTTP client exposes .query() and .cache next to the plain surface:
import { const tacho: <C extends Context = {}>() => Builder<C, undefined, unique symbol>tacho } from "tacho";
import { function createClient<R>(opts: ClientOptions): RPCClient<R> & QueryExtras<R>createClient } from "tacho/client/http";
const const rpc: Builder<{}, undefined, unique symbol>rpc = tacho<{}>(): Builder<{}, undefined, unique symbol>tacho();
const const router: {
ping: ProcedureDef<{}, undefined, "pong">;
}
router = const rpc: <{
ping: ProcedureDef<{}, undefined, "pong">;
}>(def: {
ping: ProcedureDef<{}, undefined, "pong">;
}) => {
ping: ProcedureDef<{}, undefined, "pong">;
}
rpc({ ping: ProcedureDef<{}, undefined, "pong">ping: const rpc: Builder<{}, undefined, unique symbol>rpc.run: <"pong">(fn: (opts: {
input: undefined;
ctx: {};
signal: AbortSignal;
}) => "pong" | Promise<"pong">) => ProcedureDef<{}, undefined, "pong">
run(() => "pong" as type const = "pong"const) });
type type Router = {
ping: ProcedureDef<{}, undefined, "pong">;
}
Router = typeof const router: {
ping: ProcedureDef<{}, undefined, "pong">;
}
router;
const const client: RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
client = createClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}>(opts: ClientOptions): RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
createClient<type Router = {
ping: ProcedureDef<{}, undefined, "pong">;
}
Router>({ url: stringurl: "/rpc" });
await const client: RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
client.ping: (input?: undefined, opts?: CallOptions | undefined) => Promise<"pong">ping(); // always hits the wire
await const client: RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
client.query: (opts?: QueryOptions) => RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
query().ping: (input?: undefined, opts?: CallOptions | undefined) => Promise<"pong">ping(); // same call, but cached with key ["ping"]
The default key is [methodPath, input], hashed stably — object key order in the input does not matter. Identical in-flight calls share one request instead of racing. Pass options to the surface:
import { const tacho: <C extends Context = {}>() => Builder<C, undefined, unique symbol>tacho } from "tacho";
import { function createClient<R>(opts: ClientOptions): RPCClient<R> & QueryExtras<R>createClient } from "tacho/client/http";
import { import zz } from "zod";
const const rpc: Builder<{}, undefined, unique symbol>rpc = tacho<{}>(): Builder<{}, undefined, unique symbol>tacho();
const const router: {
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}
router = const rpc: <{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>(def: {
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}) => {
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}
rpc({
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
}
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>
list: const rpc: Builder<{}, undefined, unique symbol>rpc
.input<{
done: boolean;
}>(schema: Schema<{
done: boolean;
}>): Builder<{}, {
done: boolean;
}, unique symbol>
input(import zz.function object<{
done: z.ZodBoolean;
}>(shape?: {
done: z.ZodBoolean;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
done: z.ZodBoolean;
}, z.core.$strip>
object({ done: z.ZodBooleandone: import zz.function boolean(params?: string | z.core.$ZodBooleanParams): z.ZodBooleanboolean() }))
.run: <string[]>(fn: (opts: {
input: {
done: boolean;
};
ctx: {};
signal: AbortSignal;
}) => string[] | Promise<string[]>) => ProcedureDef<{}, {
done: boolean;
}, string[]>
run(() => [] as string[]),
},
});
type type Router = {
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}
Router = typeof const router: {
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}
router;
const const client: RPCClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}> & QueryExtras<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>
client = createClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>(opts: ClientOptions): RPCClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}> & QueryExtras<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>
createClient<type Router = {
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}
Router>({ url: stringurl: "/rpc" });
const const q: RPCClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>
q = const client: RPCClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}> & QueryExtras<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>
client.query: (opts?: QueryOptions) => RPCClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>
query({ staleTime?: number | undefinedHow long results stay fresh in ms. Default: forever, until invalidated.staleTime: 30_000 });
await const q: RPCClient<{
task: {
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
};
}>
q.task: RPCClient<{
list: ProcedureDef<{}, {
done: boolean;
}, string[]>;
}>
task.list: (input: {
done: boolean;
}, opts?: CallOptions | undefined) => Promise<string[]>
list({ done: booleandone: false }); // fresh for 30s, then refetched
A custom key namespaces every call made through that surface; entries still split per input:
import { const tacho: <C extends Context = {}>() => Builder<C, undefined, unique symbol>tacho } from "tacho";
import { function createClient<R>(opts: ClientOptions): RPCClient<R> & QueryExtras<R>createClient } from "tacho/client/http";
const const rpc: Builder<{}, undefined, unique symbol>rpc = tacho<{}>(): Builder<{}, undefined, unique symbol>tacho();
const const router: {
ping: ProcedureDef<{}, undefined, "pong">;
}
router = const rpc: <{
ping: ProcedureDef<{}, undefined, "pong">;
}>(def: {
ping: ProcedureDef<{}, undefined, "pong">;
}) => {
ping: ProcedureDef<{}, undefined, "pong">;
}
rpc({ ping: ProcedureDef<{}, undefined, "pong">ping: const rpc: Builder<{}, undefined, unique symbol>rpc.run: <"pong">(fn: (opts: {
input: undefined;
ctx: {};
signal: AbortSignal;
}) => "pong" | Promise<"pong">) => ProcedureDef<{}, undefined, "pong">
run(() => "pong" as type const = "pong"const) });
type type Router = {
ping: ProcedureDef<{}, undefined, "pong">;
}
Router = typeof const router: {
ping: ProcedureDef<{}, undefined, "pong">;
}
router;
const const client: RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
client = createClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}>(opts: ClientOptions): RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
createClient<type Router = {
ping: ProcedureDef<{}, undefined, "pong">;
}
Router>({ url: stringurl: "/rpc" });
await const client: RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}> & QueryExtras<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
client.query: (opts?: QueryOptions) => RPCClient<{
ping: ProcedureDef<{}, undefined, "pong">;
}>
query({ key?: unknown[] | undefinedCustom cache key prefix. Default for RPC clients is the method path;
default for wrapped functions is a session identity plus the arguments.key: ["page", 1] }).ping: (input?: undefined, opts?: CallOptions | undefined) => Promise<"pong">ping(); // cached under ["page", 1, ...]
What is never cached:
- Streams (
async function*procedures). They come back as generators. - File uploads — inputs containing
File/Blobhave no stable hash and bypass the cache. - Failures. A rejected call drops its entry so the next call retries.
Invalidation
The .cache handle controls everything the client has cached:
await const client: RPCClient<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}>
client.cache: QueryCacheApicache.function invalidate(match?: InvalidateMatch, opts?: {
refetch?: boolean;
}): Promise<void>
Drop matching cache entries. With `{ refetch: true }`, re-run them from the wire.invalidate(["user.get"]); // prefix match on key segments
await const client: RPCClient<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}>
client.cache: QueryCacheApicache.function invalidate(match?: InvalidateMatch, opts?: {
refetch?: boolean;
}): Promise<void>
Drop matching cache entries. With `{ refetch: true }`, re-run them from the wire.invalidate((e: CacheEntryInfoe) => e: CacheEntryInfoe.method: stringmethod.String.startsWith(searchString: string, position?: number): booleanReturns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.startsWith("user."));
await const client: RPCClient<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}>
client.cache: QueryCacheApicache.function invalidate(match?: InvalidateMatch, opts?: {
refetch?: boolean;
}): Promise<void>
Drop matching cache entries. With `{ refetch: true }`, re-run them from the wire.invalidate(["user.get"], { refetch?: boolean | undefinedrefetch: true }); // re-run stored params now
const client: RPCClient<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}>
client.cache: QueryCacheApicache.function keys(): unknown[][]Keys of cached entries. Order is unspecified.keys();
const client: RPCClient<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, undefined, {
id: string;
}>;
rename: ProcedureDef<{}, undefined, undefined>;
};
}>
client.cache: QueryCacheApicache.function clear(): voidDrop everything.clear();
After a mutation, invalidate what it touched:
await const client: RPCClient<{
user: {
get: ProcedureDef<{}, {
id: string;
}, {
id: string;
}>;
rename: ProcedureDef<{}, {
id: string;
}, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, {
id: string;
}, {
id: string;
}>;
rename: ProcedureDef<{}, {
id: string;
}, undefined>;
};
}>
client.user: RPCClient<{
get: ProcedureDef<{}, {
id: string;
}, {
id: string;
}>;
rename: ProcedureDef<{}, {
id: string;
}, undefined>;
}>
user.rename: (input: {
id: string;
}, opts?: CallOptions | undefined) => Promise<undefined>
rename({ id: stringid: "1" });
await const client: RPCClient<{
user: {
get: ProcedureDef<{}, {
id: string;
}, {
id: string;
}>;
rename: ProcedureDef<{}, {
id: string;
}, undefined>;
};
}> & QueryExtras<{
user: {
get: ProcedureDef<{}, {
id: string;
}, {
id: string;
}>;
rename: ProcedureDef<{}, {
id: string;
}, undefined>;
};
}>
client.cache: QueryCacheApicache.function invalidate(match?: InvalidateMatch, opts?: {
refetch?: boolean;
}): Promise<void>
Drop matching cache entries. With `{ refetch: true }`, re-run them from the wire.invalidate(["user.get"], { refetch?: boolean | undefinedrefetch: true });
Caveat: a router with root-level query or cache procedures shadows these handles on the typed surface.
Shared and persistent caches: createCache()
A named, sharable, optionally persistent cache. Pass the handle to wrappers or clients so they share one store — invalidating on the handle hits every consumer:
const const cache: QueryCacheApicache = function createCache(opts?: {
name?: string;
driver?: CacheDriver;
}): CacheHandle
Creates a cache handle backed by your driver. Multiple surfaces can share one
handle; give them explicit `key` prefixes so cross-surface invalidation stays
meaningful.
```ts
const cache = createCache({ name: "myapp", driver: localStorageDriver() });
const listTasks = query(listTasksRaw, { cache, key: ["tasks"] });
await cache.invalidate(["tasks"]);
```createCache({
name?: string | undefinedname: "myapp",
driver?: CacheDriver | undefineddriver: function localStorageDriver(storage?: Storage): CacheDriverStorage adapter over Web Storage (`localStorage` / `sessionStorage`).localStorageDriver(),
});
const const loadTasks: (() => Promise<unknown>) & {
invalidate(match?: ((info: {
args: [];
}) => boolean) | undefined): void;
clear(): void;
}
loadTasks = query<[], unknown>(fn: () => Promise<unknown>, opts?: QueryOptions): (() => Promise<unknown>) & {
invalidate(match?: ((info: {
args: [];
}) => boolean) | undefined): void;
clear(): void;
}
Memoizes an async function — tacho server action stubs included.
```ts
import { query } from "tacho";
import { getUser } from "./users.server";
const getUserCached = query(getUser, { staleTime: 30_000 });
await getUserCached({ id: "1" }); // deduped + cached per distinct input
await getUserCached.invalidate(); // drop every cached input of this function
```
The default cache identity ties to this wrapper instance plus a stable hash
of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope
explicitly. Streams bypass the cache; failures are never cached.query(const listTasks: () => Promise<unknown>listTasks, { cache?: QueryCacheApi | undefinedA shared, optionally persistent cache returned by
{@link
createCache
}
.cache, key?: unknown[] | undefinedCustom cache key prefix. Default for RPC clients is the method path;
default for wrapped functions is a session identity plus the arguments.key: ["tasks"] });
const const loadUsers: (() => Promise<unknown>) & {
invalidate(match?: ((info: {
args: [];
}) => boolean) | undefined): void;
clear(): void;
}
loadUsers = query<[], unknown>(fn: () => Promise<unknown>, opts?: QueryOptions): (() => Promise<unknown>) & {
invalidate(match?: ((info: {
args: [];
}) => boolean) | undefined): void;
clear(): void;
}
Memoizes an async function — tacho server action stubs included.
```ts
import { query } from "tacho";
import { getUser } from "./users.server";
const getUserCached = query(getUser, { staleTime: 30_000 });
await getUserCached({ id: "1" }); // deduped + cached per distinct input
await getUserCached.invalidate(); // drop every cached input of this function
```
The default cache identity ties to this wrapper instance plus a stable hash
of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope
explicitly. Streams bypass the cache; failures are never cached.query(const getUsers: () => Promise<unknown>getUsers, { cache?: QueryCacheApi | undefinedA shared, optionally persistent cache returned by
{@link
createCache
}
.cache, key?: unknown[] | undefinedCustom cache key prefix. Default for RPC clients is the method path;
default for wrapped functions is a session identity plus the arguments.key: ["users"] });
await const cache: QueryCacheApicache.function invalidate(match?: InvalidateMatch, opts?: {
refetch?: boolean;
}): Promise<void>
Drop matching cache entries. With `{ refetch: true }`, re-run them from the wire.invalidate(["tasks"]); // tasks only, users untouched
HTTP clients accept the same handle so .query() surfaces share it:
const client = createClient<Router>({ url: "/rpc", cache });
A driver persists settled values as namespaced JSON envelopes and hydrates them once up front; entries whose fresh window expired on disk are ignored. Anything that cannot survive a JSON round-trip (File, Blob, class instances) is not persisted. Failures and in-flight calls never touch storage.
localStorageDriver() wraps Web Storage. For Redis, IndexedDB, or anything else, implement four methods:
const const redisDriver: CacheDriverredisDriver: type CacheDriver = {
keys(): string[] | Promise<string[]>;
get(key: string): string | undefined | Promise<string | undefined>;
set(key: string, value: string): void | Promise<void>;
delete(key: string): void | Promise<void>;
}
Minimal storage contract for
{@link
createCache
}
. Values are JSON strings.CacheDriver = {
function keys(): string[] | Promise<string[]>keys: async () => (await const redis: {
keys(p: string): Promise<string[]>;
get(k: string): Promise<string | undefined>;
set(k: string, v: string): Promise<void>;
del(k: string): Promise<void>;
}
redis.function keys(p: string): Promise<string[]>keys("app:*")).Array<string>.map<string>(callbackfn: (value: string, index: number, array: string[]) => string, thisArg?: any): string[]Calls a defined callback function on each element of an array, and returns an array that contains the results.map((k: stringk) => k: stringk.String.slice(start?: number, end?: number): stringReturns a section of a string.slice(4)),
function get(key: string): string | undefined | Promise<string | undefined>get: (_key: string_key) => var undefinedundefined,
function set(key: string, value: string): void | Promise<void>set: async () => {},
function delete(key: string): void | Promise<void>delete: async () => {},
};
When persisting, pass explicit
keys to your wrappers — default wrapper identities reset with each session.
Wrap any function: query(fn, opts?)
No typed router client in hand? Wrap the function directly. Works for plain fetch helpers or any async call:
// @filename: app.ts
import { function query<A extends unknown[], T>(fn: (...args: A) => Promise<T>, opts?: QueryOptions): ((...args: A) => Promise<T>) & {
invalidate(match?: (info: {
args: A;
}) => boolean): void;
clear(): void;
}
Memoizes an async function — tacho server action stubs included.
```ts
import { query } from "tacho";
import { getUser } from "./users.server";
const getUserCached = query(getUser, { staleTime: 30_000 });
await getUserCached({ id: "1" }); // deduped + cached per distinct input
await getUserCached.invalidate(); // drop every cached input of this function
```
The default cache identity ties to this wrapper instance plus a stable hash
of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope
explicitly. Streams bypass the cache; failures are never cached.query } from "tacho";
import { const getUser: (i: {
id: string;
}) => Promise<{
id: string;
}>
getUser } from "./api";
const const loadUser: ((i: {
id: string;
}) => Promise<{
id: string;
}>) & {
invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void;
clear(): void;
}
loadUser = query<[i: {
id: string;
}], {
id: string;
}>(fn: (i: {
id: string;
}) => Promise<{
id: string;
}>, opts?: QueryOptions): ((i: {
id: string;
}) => Promise<{
id: string;
}>) & {
invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void;
clear(): void;
}
Memoizes an async function — tacho server action stubs included.
```ts
import { query } from "tacho";
import { getUser } from "./users.server";
const getUserCached = query(getUser, { staleTime: 30_000 });
await getUserCached({ id: "1" }); // deduped + cached per distinct input
await getUserCached.invalidate(); // drop every cached input of this function
```
The default cache identity ties to this wrapper instance plus a stable hash
of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope
explicitly. Streams bypass the cache; failures are never cached.query(const getUser: (i: {
id: string;
}) => Promise<{
id: string;
}>
getUser, { staleTime?: number | undefinedHow long results stay fresh in ms. Default: forever, until invalidated.staleTime: 30_000 });
await const loadUser: (i: {
id: string;
}) => Promise<{
id: string;
}>
loadUser({ id: stringid: "1" }); // deduped + cached per distinct input
await const loadUser: ((i: {
id: string;
}) => Promise<{
id: string;
}>) & {
invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void;
clear(): void;
}
loadUser.function invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void
Drop cached entries; pass a predicate over the call args to filter.invalidate();
await const loadUser: ((i: {
id: string;
}) => Promise<{
id: string;
}>) & {
invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void;
clear(): void;
}
loadUser.function invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void
Drop cached entries; pass a predicate over the call args to filter.invalidate(({ args: [i: {
id: string;
}]
args }) => args: [i: {
id: string;
}]
args[0].id: stringid === "1"); // selective
const loadUser: ((i: {
id: string;
}) => Promise<{
id: string;
}>) & {
invalidate(match?: ((info: {
args: [i: {
id: string;
}];
}) => boolean) | undefined): void;
clear(): void;
}
loadUser.function clear(): voidclear();
The default cache identity ties to the wrapper instance plus a stable hash of the arguments. Two wrappers never collide. Pass { key: ["users"] } to pin the invalidation scope explicitly — useful across hot reloads, since instance identity resets when code reloads.
Options are shared with the client surface: staleTime (default: forever, until invalidated) and key.