> hypequery
Query Building

Query Caching

Cache query-builder execute() results with tags, TTLs, and custom providers.

Query Caching

Query caching belongs to the typed ClickHouse builder in @hypequery/clickhouse. It caches the result of execute() so repeated reads do not always hit ClickHouse.

Configure a cache provider

Start by enabling caching on the db client:

import { createQueryBuilder, MemoryCacheProvider } from '@hypequery/clickhouse';

const db = createQueryBuilder({
  host: process.env.CLICKHOUSE_HOST!,
  cache: {
    mode: 'stale-while-revalidate',
    ttlMs: 2_000,
    staleTtlMs: 30_000,
    staleIfError: true,
    provider: new MemoryCacheProvider({ maxEntries: 1_000 }),
  },
});

Cache an individual query

Use .cache(...) on the builder chain:

const rows = await db
  .table('orders')
  .sum('total', 'revenue')
  .groupBy(['customer_id'])
  .orderBy('revenue', 'DESC')
  .limit(10)
  .cache({ tags: ['orders'], ttlMs: 5_000 })
  .execute();

Use .cache(...) when the query should normally be cached everywhere it is used.

For one-off overrides, pass cache options directly to execute(...):

const rows = await db
  .table('orders')
  .sum('total', 'revenue')
  .groupBy(['customer_id'])
  .execute({
    cache: {
      mode: 'network-first',
      ttlMs: 1_000,
      tags: ['orders', 'dashboards'],
    },
  });

Cache modes

ModeDescription
cache-firstServe hot entries, otherwise fetch and store.
network-firstAlways hit ClickHouse; fall back to stale data when staleIfError is enabled.
stale-while-revalidateServe stale-but-acceptable results immediately and refresh in the background.
no-storeSkip caching entirely.

Recommended defaults:

  • Use cache-first for dashboards and read-heavy widgets where slightly stale data is fine.
  • Use network-first when freshness matters more than latency, but you still want stale fallback during ClickHouse failures.
  • Use stale-while-revalidate when you want fast responses for repeated reads without blocking on a refresh.
  • Use no-store to bypass caching for highly volatile or user-specific reads.

Cache options

OptionWhat it does
ttlMsHow long an entry is considered fresh.
staleTtlMsExtra time an entry may still be served as stale.
cacheTimeMsTotal time the provider should retain the entry. Defaults to ttlMs + staleTtlMs.
staleIfErrorIn network-first, serve stale data if ClickHouse fails and a stale entry is available.
dedupeReuse in-flight fetches for the same cache key instead of sending duplicate queries.
tagsAttach tags for later invalidation.
keyOverride the generated cache key when you want multiple query shapes to share one entry.
namespaceIsolate entries from other cache consumers sharing the same provider.
serialize / deserializeCustomize how cached results are encoded and decoded before storage.

By default, hypequery generates deterministic cache keys from the SQL, parameters, and settings for the query.

Invalidate and inspect cache state

await db.cache.invalidateKey('hq:v1:analytics:orders:abc123');
await db.cache.invalidateTags(['orders', 'dashboards']);
await db.cache.clear();

await db.cache.warm([
  () => db.table('orders').select(['id']).cache({ tags: ['orders'] }).execute(),
  () => db.table('users').select(['id']).cache({ tags: ['users'] }).execute(),
]);

const stats = db.cache.getStats();
console.log(stats.hitRate, stats.hits, stats.misses, stats.staleHits, stats.revalidations);

invalidateTags(...) requires the active cache provider to implement deleteByTag(...). If it does not, hypequery will warn and only clear its in-memory parsed values for matching tags.

Bring your own provider

Use a custom CacheProvider when you want Redis, Upstash, KV, or another shared store:

import type { CacheEntry, CacheProvider } from '@hypequery/clickhouse';
import { Redis } from 'ioredis';

class RedisCacheProvider implements CacheProvider<string> {
  constructor(private readonly client = new Redis(process.env.REDIS_URL!)) {}

  async get(key: string) {
    const raw = await this.client.get(key);
    return raw ? (JSON.parse(raw) as CacheEntry) : null;
  }

  async set(key: string, entry: CacheEntry) {
    await this.client.set(key, JSON.stringify(entry), 'PX', entry.cacheTimeMs ?? entry.ttlMs);
  }

  async delete(key: string) {
    await this.client.del(key);
  }

  async deleteByTag(namespace: string, tag: string) {
    const tagKey = `hq:tag:${namespace}:${tag}`;
    const keys = await this.client.smembers(tagKey);
    if (keys.length) await this.client.del(...keys);
    await this.client.del(tagKey);
  }
}

See Also

On this page