Rate limiting & size budgets

How public write paths are throttled, and how a submission's payload is kept bounded.

Two-tier rate limiting

Public write paths (RSVPs, custom form submissions) combine two independent limiters, composed by isRateLimited (src/lib/rate-limit.ts:59):

  • An in-memory, per-server-instance floor: isThrottled (src/lib/rate-limit.ts:38) keys a plain Map (src/lib/rate-limit.ts:31) by a caller-supplied key (usually the client IP from getClientIp, src/lib/rate-limit.ts:12) and rejects a second hit within minIntervalMs. The map is capped at MAX_ENTRIES = 10_000 (src/lib/rate-limit.ts:36), pruning the oldest key once full. It only holds per instance — on a multi-instance deployment each instance has its own map, so the effective limit is “per key, per instance,” not global.
  • A cross-instance, DB-backed sliding window: checkRateLimit (src/lib/data/rate-limit.ts:23) counts and records hits in public.rate_limit_hits (src/lib/data/rate-limit.ts:15), so the limit holds regardless of how many server instances are handling traffic.

The DB-backed tier fails open: if the count query against rate_limit_hits errors, checkRateLimit logs and returns false (not rate-limited) rather than blocking the write path over an unreachable store (src/lib/data/rate-limit.ts:41–src/lib/data/rate-limit.ts:47) — the in-memory floor still applies as a fallback layer either way.

// Fail open: if the rate-limit store itself is unreachable, don't take
// down the public write path over it — the in-memory floor in
// src/lib/rate-limit.ts still applies as a fallback layer.
if (error) {
  console.error(`Rate-limit check failed for key ${key}:`, error.message);
  return false;
}

Payload-size and per-field caps

Every submission is checked against a total-size cap: assertWithinSizeBudget (src/lib/schemas/size-budget.ts:10) throws if the JSON-encoded payload exceeds MAX_PAYLOAD_BYTES = 64 * 1024 (src/lib/schemas/size-budget.ts:8) — 64 KB — shared by both the RSVP engine and the generic forms engine via assertResponsesWithinSizeBudget (src/lib/schemas/responses.ts:108).

Underneath that total cap, individual fields are already capped per-value: RSVP responses are sanitized by sanitizeScalar/sanitizeList (src/lib/schemas/responses.ts:5, src/lib/schemas/responses.ts:9) against MAX_NAME_LENGTH = 100 and MAX_GUESTS = 15 (src/lib/rsvp-limits.ts:1–src/lib/rsvp-limits.ts:2), a per-field maxLength falling back to MAX_NAME_LENGTH when unset. A checkbox-group value is additionally deduped and capped to its own option count via sanitizeCheckboxValues (src/lib/schemas/responses.ts:21) so a repeated-value payload can’t inflate the stored array past what the field’s own options allow.