Serializing Dates and Sets in Query Keys

JSON.stringify(new Set([1, 2])) returns "{}". So does JSON.stringify(new Set([3, 4])). Put a Set of filters in a query key and every distinct filter combination addresses the same cache entry, so two screens with different filters read each other’s data — a correctness bug that produces no error and looks like a backend problem. Date fails in the opposite direction, creating a new entry per render. This page is the encoder that fixes both, under Cache Key Serialization & Hashing. The serialization rules behind these behaviours are in Hashing Query Keys Deterministically, and the namespace discipline that keeps the encoded keys apart is in Avoiding Query Key Collisions Across Features.

Diagnostic Checklist

Prerequisites

  • A key factory, or the willingness to introduce one.
  • A list of the non-primitive values your filters actually carry.
  • The contract tests from Hashing Query Keys Deterministically, which is where the encoder’s behaviour gets asserted.
Four failure shapes Four rows. A Date becomes a millisecond-precision ISO string, producing too many entries. A Set becomes the empty object, producing too few. A class instance loses its getters, producing a wrong entry. A BigInt throws. Each row shows the encoded form that fixes it. Two of these create too many entries, two create the wrong one new Date() "2026-07-31T09:14:22.118Z" a new entry per render bucket to the day "2026-07-31" new Set(['a','b']) "{}" every set is ONE entry sorted array ["a","b"] class instance own fields only getters silently dropped throw in development pass an explicit primitive instead 10n (BigInt) TypeError crashes inside the library suffixed string "10n"
The Set row is the dangerous one: it is the only failure that makes two different queries read one entry's data.

Step 1 — Bucket a Date to the Precision You Mean

A caller passing new Date() means “now, roughly”. Encode the roughness explicitly.

export type DateBucket = 'day' | 'hour' | 'minute';

export function encodeDate(value: Date, bucket: DateBucket = 'day'): string {
  const iso = value.toISOString();
  // Slice to the precision the query actually depends on. A dashboard that
  // refreshes hourly gains nothing from millisecond resolution in its key.
  if (bucket === 'day') return iso.slice(0, 10);      // 2026-07-31
  if (bucket === 'hour') return iso.slice(0, 13);     // 2026-07-31T09
  return iso.slice(0, 16);                            // 2026-07-31T09:14
}

export const activityKeys = {
  all: ['activity'] as const,
  since: (from: Date, bucket: DateBucket = 'hour') =>
    [...activityKeys.all, 'since', encodeDate(from, bucket)] as const,
};

Cache Behavior Analysis: Slicing an ISO string is safe because toISOString always produces UTC in a fixed format, so the same instant yields the same prefix in Node and in every browser — a toLocaleDateString would not, and is a common cause of a server-rendered key disagreeing with its client counterpart. Bucketing to the hour means every render within that hour lands on one entry, so a component that recomputes new Date() on each pass stops creating entries entirely. Match the bucket to staleTime: an hour bucket with a five-minute staleTime revalidates sensibly, while a day bucket with a five-minute staleTime refetches the same address twelve times an hour, which is fine but means the bucket is doing no work.


Step 2 — Convert Set and Map to Sorted Arrays

Both serialize to {}. Converting them to sorted arrays restores both distinctness and order-independence.

export function encodeSet(value: Set<string | number>): string[] {
  // Sorted so that a set built in a different insertion order is the same key.
  return [...value].map(String).sort();
}

export function encodeMap(value: Map<string, unknown>): Array<[string, unknown]> {
  return [...value.entries()]
    .map(([key, entry]) => [key, encodeKeyValue(entry)] as [string, unknown])
    .sort((a, b) => (a[0] < b[0] ? -1 : 1));
}

export const todoKeys = {
  all: ['todos'] as const,
  byTags: (tags: Set<string>) => [...todoKeys.all, 'tags', encodeSet(tags)] as const,
};

Cache Behavior Analysis: Sorting is not optional here even though the default hash sorts objects, because the sorting replacer only applies to plain objects — arrays keep their order, so ['a','b'] and ['b','a'] are genuinely different keys and an unsorted conversion would trade one bug for another. Mapping through String before sorting keeps mixed-type sets deterministic; a set containing both 1 and '1' collapses to one entry, which is almost always what the caller meant and is worth being explicit about in a comment. Recursing through encodeKeyValue for map values handles the nested case, where a map holds sets or dates.

Cache entries for four distinct tag filters Bar chart of distinct cache entries created. A raw Set produces 1 entry for all four filters. An unsorted array produces 8. A sorted encoded array produces 4, the correct number. Cache entries for four distinct tag filters Each filter applied twice, in different insertion order raw Set in the key 1 entries unsorted array 8 entries sorted encoded array 4 entries
One entry means the four filters share data; eight means none of them share with themselves. Only the sorted encoding is right.

Step 3 — Reject Class Instances Loudly

A class instance serializes its own enumerable fields and drops everything else. There is no encoding that recovers the missing information, so the correct response is to refuse.

export function encodeKeyValue(value: unknown): unknown {
  if (value instanceof Date) return encodeDate(value);
  if (value instanceof Set) return encodeSet(value as Set<string | number>);
  if (value instanceof Map) return encodeMap(value as Map<string, unknown>);
  if (typeof value === 'bigint') return `${value}n`;
  if (Array.isArray(value)) return value.map(encodeKeyValue);

  if (value && typeof value === 'object') {
    const prototype = Object.getPrototypeOf(value);
    if (prototype !== null && prototype !== Object.prototype) {
      if (process.env.NODE_ENV !== 'production') {
        throw new Error(
          `Query keys cannot contain ${prototype.constructor?.name ?? 'class'} instances — ` +
            'pass the primitive fields the query actually varies on.',
        );
      }
      // Production: deterministic, possibly lossy, but never a crash.
      return String(value);
    }
    return Object.fromEntries(
      Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, encodeKeyValue(v)]),
    );
  }

  return value;
}

Cache Behavior Analysis: Throwing in development and coercing in production keeps the failure loud where it is cheap to fix and survivable where it is not — a key that silently changes shape after a deploy is worse than one that is slightly lossy. The BigInt branch matters more than it looks: without it JSON.stringify throws a TypeError from deep inside the library’s hashing code, which surfaces as an opaque stack trace with no mention of the key that caused it. Recursing into plain objects rather than returning them unchanged is what makes the encoder work on nested filter shapes, which is how a Date ends up two levels down without anyone noticing.


Step 4 — Apply It Inside the Factory

An encoder that call sites must remember to call is an encoder that will be forgotten. Put it where every key passes through.

type Encodable = Record<string, unknown>;

function params(input: Encodable | undefined): Record<string, unknown> {
  if (!input) return {};
  const encoded: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(input)) {
    // Drop empties so an omitted filter and an explicit undefined agree.
    if (value === undefined || value === null) continue;
    if (value instanceof Set && value.size === 0) continue;
    if (Array.isArray(value) && value.length === 0) continue;
    encoded[key] = encodeKeyValue(value);
  }
  return encoded;
}

export const reportKeys = {
  all: ['reports'] as const,
  list: (filters?: { from?: Date; tags?: Set<string>; owner?: string | null }) =>
    [...reportKeys.all, 'list', params(filters)] as const,
};

// Every one of these lands on the same address.
reportKeys.list({ from: new Date(), tags: new Set(['a', 'b']) });
reportKeys.list({ tags: new Set(['b', 'a']), from: new Date(), owner: null });

Cache Behavior Analysis: Dropping null and undefined collapses the three ways a caller can express “no owner filter” into one address, which matters because JSON.stringify omits undefined properties but keeps null ones — without this, the same intent produces two entries. Treating an empty Set or array as absent applies the same reasoning one level down. Because the encoding happens inside the factory, a call site that passes a raw Date is handled correctly without knowing the encoder exists, which is the property that makes this survive new contributors.

Encoding decisions and what each one costs Matrix of five value types with the chosen encoding and the trade-off it carries. Encoding decisions and what each one costs Encoded as Trade-off Date ISO string sliced to a bucket Queries within a bucket share an entry — match the bucket to staleTime Set Sorted array of strings Mixed 1 and '1' collapse into one member Map Sorted array of entry pairs Insertion order is deliberately discarded BigInt Decimal string with an n suffix Cannot round-trip back to a BigInt from the key Class instance Rejected in dev, String() in production No encoding can recover getters or prototype data
Every row trades some precision for a stable address. The question is always which precision the query genuinely depends on.

Edge Cases & Gotchas

Time zones. toISOString is UTC, so a “today” bucket rolls over at UTC midnight rather than the user’s. If the query genuinely means the user’s day, convert to their zone first and include the zone in the key — otherwise two users in different zones share an entry that is correct for neither.

Sets of objects. [...set].map(String) on a set of objects yields "[object Object]" for every member. Extract a stable id before encoding, or the set collapses exactly as it would have unencoded.

Round-tripping. The encoded key is an address, not a payload. Do not try to recover the original Date or Set from it inside queryFn — pass the real values as closure variables and let the key carry only their identity.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Different filter sets return identical data A Set or Map serialized to {} Encode as a sorted array inside the factory
A new cache entry appears every render A Date with millisecond precision is in the key Bucket the date to day, hour or minute
The library throws a TypeError while hashing A BigInt reached JSON.stringify Encode it as a suffixed string
Two orders of the same tags create two entries The set was converted to an array without sorting Sort after converting
Server and client fetch the same data twice The date was formatted with a locale-dependent method Use toISOString and slice

Frequently Asked Questions

Which of these values is the most dangerous?

Set and Map, by a wide margin, because both serialize to "{}" and therefore make every distinct value address one cache entry. That is a correctness failure rather than a performance one: two screens with genuinely different filters read the same entry, so each sees data fetched for the other’s filter, and the symptom looks like a backend bug. Date is the opposite failure — too many entries rather than too few — and while it wastes memory and destroys the hit rate, it never shows wrong data. Fix the sets first.

Why not just memoize the Date at the call site?

Because a useMemo is scoped to one component instance, so navigating away and back creates a new Date and therefore a new cache entry — the memo suppresses the churn within a mount and does nothing across mounts, which is where most of it comes from. It also has to be remembered at every call site, and the one that forgets is the one that fills the cache. Bucketing inside the factory makes the address a function of the query’s meaning rather than of when the component happened to render, which is both correct and impossible to forget.

Should the encoder run in production?

The conversions yes, the throwing no. Date, Set, Map and BigInt all need encoding in every environment, or production keys differ from the ones your tests exercised. The throw for class instances is a development-only guard: it makes an unsupported type impossible to ship, while the production fallback to String(value) guarantees that a type someone added after the guard was written degrades into a possibly-imprecise address rather than crashing a screen. Log the production fallback so you find out about it.