Skip to content

Documentation

Performance Architecture

2027-ready allocation-floor database layer with findPage/hasMore, count modes (exact|estimate|auto), short-lived count cache, schema-aware conversion, and zero-tax SDK dispatching.

9/20/2026
81 min read Edit on GitHub
On this page

πŸ“Š Performance Benchmarks (2026-2027)

SveltyCMS delivers enterprise-grade performance through its database-agnostic architecture. For the complete set of verified benchmarks, stress test results, and hardware context, please refer to the Performance Benchmarks document.

The 4-Cell Benchmark Contract {#4-cell-benchmark-contract}

Every performance claim about the data layer is classified into one of four cells, so an in-memory cache hit can never be presented as database throughput (the category error this contract exists to prevent):

Cell Classification What it measures Target / behaviour
Cell 1: L1 / 304 Hit Cache Plane In-memory single-flight / HTTP 304 If-None-Match short-circuit 10 000+ RPS (zero DB I/O, independent of driver)
Cell 2: Origin Miss (Domain Plane) Canonical CMS Miss Drizzle ORM query + DTO mapping + FLAC + JSON.stringify() 500–1 500 RPS (baseline canonical pipeline)
Cell 3: Origin Miss (Wire Plane) Accelerated Wire Miss Pre-compiled json_build_object / json_object streaming Measurable RPS↑, p95↓, RSS↓ vs Cell 2 (PostgreSQL & SQLite)
Cell 4: Raw Driver Ceiling Physical Driver Limit Bare driver throughput via raw-db-ceiling.ts, plus its raw HTTP floor Machine-specific reference printed by raw-db-ceiling.ts on that run
Important

Merge laws

  1. Cell 1 must never appear in a β€œvs ceiling” percentage. It is an in-memory short-circuit; claiming 10k+ RPS as database performance is a category error.
  2. Cell 3 must beat Cell 2 on PostgreSQL and SQLite on the identical commit, verified with byte-identity envelope snapshots (trimPointReadEnvelope). Still pending β€” the compiled point path ships on all four adapters, list-wire is not routed from any route, and no Cell-3 matrix has been published; see roadmap-2026.
  3. Cell 4 stays a machine-specific reference ceiling, never a target the CMS claims to exceed.
  4. The honest yardstick for HTTP-layer records is % of the raw HTTP floor (driver + HTTP + JSON framing), not % of the in-process driver ceiling β€” the latter compares across domains. On SQLite the floor is what binds (driver β‰ˆ 250k RPS in-process, HTTP floor 29.2k for a point read), which is why findById measures ~32 % of the floor while listPlain reaches ~59 %.

SQLite β€” Local/Edge (Verified August 2026)

Operation Latency RPS
FIND ONE 0.006 ms 129,080
FIND MANY (50) 0.056 ms 14,215
INSERT 0.064 ms 5,498–13,574
UPDATE 0.030 ms 12,735–14,956
DELETE 0.064 ms 8,158–14,422
NATIVE UPSERT 0.068 ms 9,009–12,227
COUNT CACHED 0.001 ms 491,434 req/s
LocalCMS SDK (Warm L1) 0.001 ms 511,829 req/s
LocalCMS SDK Overhead β€” 0.00% (verified parity)
Note

Adapter findOne (129k RPS) is ~47% of the raw in-memory SQLite engine ceiling (~274k) β€” the remaining delta is Drizzle AST + date conversion on the non-raw path. findMany with a pure _id filter routes through the same raw path and measures ~126k RPS. INSERT uses a raw INSERT…RETURNING fast path (85% of the 16.7k ceiling) β€” the benchmark measures the production path (the ambient BENCHMARK env no longer toggles skipReturning; only the explicit seed option does). Warm L1 micro-caching serves findById in 0.001ms (511k RPS), outperforming the underlying disk engine.

Dual-Runtime Profile (Node.js V8 vs. Bun JavaScriptCore)

SveltyCMS provides first-class support for both Node.js (>=24) and Bun (>=1.3 / 1.4):

  • Node.js 24+ (V8 TurboFan): Recommended for long-running production servers. In full-stack CMS workloads involving deep middleware chains, session HMAC cryptography, GraphQL AST parsing, and Drizzle query construction, V8’s TurboFan JIT compiler delivers 1.8Γ— to 2.4Γ— higher sustained throughput (4,000+ RPS on findById) and tighter p99 tail latency.
    • V8 Young Generation Tuning & Soak-Test Hardening: In high-throughput 60s soak tests (1,342 avg RPS, peak 2,398 RPS, 81,488 total requests), SvelteKit allocates 30–35 MB/s of ephemeral objects (Request, Response, URL, locals, ASTs). With the Node.js default 16 MB Semi-Space, the Young Generation overflows in milliseconds, forcing V8 to promote short-lived garbage into Old Space (Premature Tenuring). This accumulates ~500 MB of garbage and causes major Full GC pauses at ~15s and ~55s.
    • The Fix: Setting NODE_OPTIONS="--max-semi-space-size=64 --max-old-space-size=1024" (matching the production Dockerfile) expands the young generation headroom. V8 cleans up transient allocations in sub-millisecond minor scavenge cycles (<1 ms) without tenuring them into Old Space, eliminating GC throughput drops and stabilizing memory at ~200–300 MB under sustained load. The semi-space was originally tuned to 128 MB and lowered to 64 MB on 2026-09-27 after a paired A/B (128/64/64/128, same code) showed no throughput cost with ~128 MB less committed heap; the dated operating-point numbers below were measured at 128 MB.
    • Where that operating point settles (measured 2026-09-22 at semi-space 128, 1 h soak row, SQLite, ~97 req/s): three independent runs converge on heapTotal 342.8 / 344.0 / 347.9 MB β€” the 2 Γ— 128 MB semi-space plus old space β€” with heapUsed oscillating 72–176 MB and no trend (tail slope βˆ’0.10 to +0.06 MB/min, |t| ≀ 0.3) and flat external. An idle control releases the entire commitment (RSS 221.9 β†’ 77.6 MB). Treat the committed ceiling as the young-generation budget, not as retention: it is ~3.5Γ— the post-GC live set by design. The 1 GB old-space cap is a safety budget, not a tuning target: the vendor comparison’s capped rows paid major GCs under a 384 MB cap, while our heapUsed peaks at ~176 MB (β‰ˆ17 % of 1024 MB) with a flat slope β€” raising it buys no speed and risks an OOM-kill under a real container limit. Row matrix and controls: achievements-2026.
    • Containerization: The official production Dockerfile is built on node:24-slim with these flags pre-configured, coupled with a fast, zero-dependency curl healthcheck (~3 ms vs ~200 ms Node evaluation).
  • Bun 1.4+ (Bun.serve): Recommended for developer tooling (bun install / bun test), CLI scripts, and serverless / edge containers where sub-10ms cold start latency and low idle memory are prioritized.

Core database layer (all 4 engines)

Optimizations live in shared core, not one dialect:

| Shared module | Engines | Effect | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| core/query-primitives.ts + SqlAdapterCore.findOne | SQLite, PostgreSQL, MariaDB | {_id} / {_id,tenantId} / + scalar status β†’ findById then in-memory status match (publication clamp stays on the PK ultra path; draft $in still uses full translation) | | core/query-primitives.ts (parseIdLookup + isDeleted) | SQLite, PostgreSQL, MariaDB, MongoDB | Routes { _id, isDeleted: false } soft-delete checks directly into compiled findById raw SELECT execution β€” avoids full query translation AST overhead | | | SqlAdapterCore.findMany id ultra path | SQLite, PostgreSQL, MariaDB | Pure {_id} list calls β†’ findById (after before-hooks, same semantics as findOne); ~10Γ— faster than dynamic-SQL list path | | MongoCrudMethods.findMany id ultra path | MongoDB | Same shape: {_id} list calls β†’ lean findOne, tenant from options or query | | SqlAdapterCore.findById + SQLite rawFindById | SQLite raw SELECT; PG/Maria eq+limit | Fast primary-key reads | | SqlAdapterCore.update re-read via findById | All SQL when RETURNING empty / non-returning | Faster than post-update findOne | | insertReturnsRows / updateReturnsRows | SQLite + PostgreSQL (Drizzle RETURNING); MariaDB raw UPDATE…RETURNING fast path | Single RT on all four engines | | Raw INSERT…RETURNING fast path | PostgreSQL, MariaDB, SQLite | One-RT insert via tagged template / prepared pool.execute / wrapped prepared SQL β€” skips Drizzle AST on write. PG rawInsertReturning honors skipReturning (no-read-back synthesis, seed/system callers) | | Raw UPDATE…RETURNING (PG) + no-read-back skipReturning | PostgreSQL, MariaDB, SQLite | PG: tagged-template UPDATE (314 β†’ 444 RPS, 99% of ceiling). skipReturning: true reconstructs the row from prepared values (full-doc callers): MariaDB UPDATE ~2Γ— (431 β†’ 828, up to 1.2k). SQLite: raw prepared-statement UPDATE…RETURNING via rawUpdateReturning hook (2026-08): UPDATE 0.125 β†’ 0.029 ms (6.1 β†’ 18.8k RPS), faster than INSERT; skipReturning also raw (0.071 β†’ 0.024 ms). Central param coercion in prepareAndExecute (booleanβ†’1/0, Dateβ†’epoch ms, objectβ†’JSON, Uint8Array kept binary) β€” raw paths never pre-map params (double-coercion cost + blob corruption) | | hooks/request-classifier.ts ($O(1)$ Segment Sets) | HTTP Request Pipeline | Static Set lookups (CACHEABLE_SEGMENTS_SET, ADMIN_APP_SEGMENTS_SET) replace linear array prefix iterations across 100% of incoming HTTP requests | | services/security/threat-scan.ts (linear WAF / AuthGuard) | HTTP Request Pipeline + payload analysis | O(n) indexOf / char-code scanners replace per-request RegExp engines for SQLi/XSS/traversal/prototype-pollution (ReDoS-proof). Clean ASCII URLs skip decodeURIComponent; honeypot and scanner-bot UA use prefix/token walks; analyzeRequest splits pathname/search without new URL(). Shared by Layer 0 WafGuard and AuthGuardService. SQL prepareValues uses hasIsoDateTimePrefix (char-code) instead of /^\d{4}-\d{2}-\d{2}T/ | | Widget glob index (scanner.getComponentLoader) | Collection list + entry editor | One-shot ${folder}:{input\|display} Map; no per-field glob scan. List cells retry when the widget registry is ready; plugin columns skip disabled plugins; plugin tabs load only when selected. 50-field resolve 0.053 β†’ 0.023 ms (self-measured 2026-08-23) | BoundedJITCache (graphql/jit-cache.ts) | GraphQL Yoga API Layer | Persistent bounded LRU compilation cache keyed by document source strings, eliminating graphql-jit query compilation overhead on repeated HTTP requests (p99 tail 22ms β†’ <1ms) | | Zero-Allocation Populate Pipeline (populate-resolver.ts) | Collections Namespace & SDK | In-place relation attachment and removal of redundant deepClone(result.data), eliminating the 40% CPU and memory tax on populated collection queries | | Collection Read Lane List Expansion (handle-collection-read-lane.ts) | HTTP API Request Pipeline | Extends the warm-session read lane to standard collection list queries (GET /api/collections/:id), serving cached responses or executing cms.collections.find directly, bypassing downstream SvelteKit hooks and router overhead | | utils/fast-json.ts (fastEscapeString Char Loop) | String Serialization | Pure integer character-code scan replaces RegExp engine evaluation for clean ASCII strings β€” 5×–10Γ— faster JSON escaping on hot serialization paths | | | utils/native-utils.ts (HEX_TABLE Token Generation) | Security / CSPRNG | 256-entry precomputed byte hex lookup table replaces .toString(16).padStart(2, "0") inside hot crypto loops β€” 3×–5Γ— faster session/token generation | | graphql/loaders.ts (Zero-Allocation Batching) | GraphQL Layer | In-place batch slicing (ids.length <= BATCH_SIZE) and direct string-keyed Map caching eliminate thousands of intermediate string/array copies per request | | SqlAdapterCore Reusable Table References (_lastTableRef) | SQLite, PostgreSQL, MariaDB | Typed instance reference eliminates per-query getter/setter closure allocations across getColumn, getPhysicalSelection, mapQuery, and prepareValues | | utils/native-utils.ts (timingSafeStringEqual) | Security / Cryptography | Constant-time O(max(lenA, lenB)) bitwise accumulator eliminates subtle CPU cache-line timing side-channel attacks across token, webhook, and signature comparisons | | utils/hook-utils.ts (STATIC_BASE_HEADER_PAIRS) | HTTP Request Middleware | Pre-filtered static header pairs array avoids per-request Object.entries allocation and conditional filtering during response construction | | databases/auth/permissions.ts Fast-Path & Zero-Alloc | Auth / RBAC Layer | Canonical isAdmin(user) short-circuit combined with direct string key checks eliminates redundant mapping and evaluation passes on high-frequency API endpoints | | core/batch-module.ts Single-Pass Map Grouping | SQLite, PostgreSQL, MariaDB | O(1) single-lookup map grouping avoids duplicate .has() + .get() hashing cycles during batch operation coalescing | | rawBulkUpdate CASE fast path (heterogeneous bulk UPDATE) | SQLite, PostgreSQL, MariaDB | Heterogeneous bulk updates (batch.bulkUpdate with per-row payloads) collapse N per-row UPDATE statements into ONE SET "col" = CASE "_id" WHEN ? THEN ? … ELSE "col" END statement (statement-cache friendly) per chunk; constant columns (updatedAt/tenantId) use plain SET. Values come from prepareUpdateValues (full crud.update parity incl. the JSON data blob), chunks run inside an explicit transaction (all-or-nothing), the WHERE is tenant-scoped (fail-closed MULTI_TENANT), and any failure falls back to the transactional per-row loop. Measured on SQLite: 100-row heterogeneous bulk UPDATE 22.7ms β†’ 0.88ms avg (25.9Γ—, 44 β†’ 930 RPS) β€” and it fixes the Drizzle .set() β€œcolumn not found” error the old loop threw for blob-field payloads on PG/MariaDB. MongoDB already coalesced via bulkWrite; its filters are now tenant-scoped and updatedAt-stamped to match crud.update. The homogeneous bulkUpdate fast path is routed through prepareValues too β€” Drizzle .set() silently DROPS keys that are not physical columns, so homogeneous blob-field payloads (e.g. { title, count }) previously returned success:true without persisting anything (the Zahl-Feld class); prepareValues moves them into the data blob and preserves number types. Write-path trims: per-table date-column key set (_dateKeyCache, WeakMap-keyed by the table def) replaces the per-key *Date/*At/*Time string heuristics + String(columnType) allocations in prepareValues (identical predicate, once per def); synthesizeInsertRow reuses the warm _tableColumnsCache instead of re-walking getTableColumns per insert (the single biggest measured adapter-write win: INSERT 0.127β†’0.048ms, UPDATE 0.114β†’0.027ms, BULK INSERT 100 1.99β†’1.10ms on the adapter benchmark, stable across runs); convertISOToDates honors inPlace for objects so the freshly-built values object is not Object.assign-copied again per write. | | | convertDatesToISO / convertISOToDates Array In-Place | SQLite, PostgreSQL, MariaDB, MongoDB | In-place array traversal inside convertDatesToISO and convertISOToDates when inPlace: true, eliminating .map() array allocations across multi-record reads | | collection-filter-engine.ts Static Field Sets | Collection Filter Service | Static pre-allocated sets (SYSTEM_FILTERABLE_FIELDS, SYSTEM_SEARCHABLE_FIELDS) eliminate per-field array literal allocations during query compilation | | convertDatesToISO zero-work fast path | All SQL | In-place conversion returns the row untouched when no Date instances and no JSON-string columns (content rows store ISO strings); on the zero-work path an already-parsed blob (PostgreSQL jsonb arrives as an object, not a string) is still flattened into the row so blob fields stay visible; skipJson: true opts out (projection-only reads) | | convertArrayDatesToISO in-place loop | All SQL + MongoDB | When inPlace: true (standard on query execution), performs direct indexed loop over the existing row array instead of allocating intermediate arrays with .map(), halving garbage collection overhead on multi-record result sets | | Pre-compiled FLAC (collection:role) allowed Sets | All API / Content Pipelines | Pre-compiles and memoizes allowed field Set instances once per (collection, role) pair and executes single-resolution response filtering via Object.hasOwn traversal, eliminating per-record new Set() and Object.keys() allocations across paginated responses | SqlQueryBuilder Compiled Plan & Table Metadata Cache (sql-query-builder.ts) | SQLite, PostgreSQL, MariaDB | WeakMap cached table metadata (pre-quoted column identifiers, projection strings, and validated safe table names) + compiledSqlCache fast-path early exit bypasses Drizzle AST construction, dynamic query rebuilding, identifier validation, and SQL string formatting on warm hits. executeCompiled runs directly on the driver statement cache (PostgreSQL prepare: true, SQLite prepareAndExecute, MariaDB pool.execute). Reduces query-builder paginated list latency from ~4.88 ms to 0.104 ms (parity with raw findMany). | | DateConversionMeta Hoisting & Registry Cache (relational-utils.ts) | All SQL + MongoDB | Hoists table schema resolution (table, hasSchema, dateCols, jsonCols, skipMerge, bools) outside the row iteration loop in convertArrayDatesToISO, and caches metadata in _dateMetaCache. Eliminates hundreds of Map lookups and string operations per batch, cutting multi-row serialization overhead across all database adapters. | | Compact Keyset Cursors (k1:{dir}:{id}) | All SQL + MongoDB | Emits and parses lightweight delimited base64url keyset tokens (<20 bytes) for standard ID-ordered pagination without JSON.stringify/JSON.parse overhead, preventing packet fragmentation over mobile & edge networks | | | Schema Allowed Filter Field Memoization | Query Filters & Smart Table | Attaches compiled _allowedFilterFieldIds Set<string> directly to the schema model on first lookup, avoiding dynamic Set construction and field iteration on every filtered list request | | Schema-Specific Fast String Builders (fast-json.ts) | Auth, Roles, Media, Nodes | Pre-compiled string builders (serializeUserSafe, serializeRoleSafe, etc.) eliminate generic V8 reflection on high-frequency models, delivering up to 3x faster JSON serialization with 0 intermediate object allocation | | Direct Pass-Through Streaming Chunks (streaming.ts) | API Streaming & Exports | streamingRawJsonResponse pipes raw database JSON slices directly to ReadableStream chunks with pre-encoded singleton tokens, avoiding JSON.parse + JSON.stringify round-trips during exports β€” but it currently has no caller; exports use the streamMany path below. Wiring is tracked in the roadmap. | | Cursor / paged streamMany export | GET /api/collections/{id}/export | Mongo cursor, PostgreSQL driver stream, SQLite/MariaDB 500-row pages; NDJSON/CSV encode one record per chunk | | Partial-update JSON merge (all write paths) | SQLite, PostgreSQL, MariaDB | A partial PATCH merges the data blob instead of replacing it β€” on the single-entry update and on updateMany / batch.bulkUpdate / query-builder updateMany. PostgreSQL merges in-statement (COALESCE("data",'{}'::jsonb) \|\| $n::jsonb), SQLite/MariaDB use json_patch / JSON_MERGE_PATCH for scalar/array patches and hydrate + merge per row for nested-object/explicit-null patches (both operators are RFC 7396: recursive, null-deleting). Contract = MongoDB $set parity: shallow, null-keeping, a patched object replaces wholesale. replaceData: true (and skipReturning: true) opts out for full-document writers; the marker lives in core/query-primitives.ts. See Write Path. | | System-schema fingerprint (svelty_schema_state) | SQLite, PostgreSQL, MariaDB | The spec-derived DDL pass (118 statements on PostgreSQL) skips itself when the stored sha-256 over SYSTEM_SCHEMA matches β€” measured 124 ms β†’ skip on a provisioned engine. Any spec change re-runs it (drift-free by construction); a failed statement prevents storing the marker so a half-applied schema retries. The legacy per-engine repair tails were deleted in the same pass, so the spec is the only schema trail (fresh database: 27 tables + 117 indexes in 327 ms once). | | Cross-engine crud.aggregate() (MongoDB pipeline on SQL) | All SQL + MongoDB | A portable subset of the MongoDB pipeline is translated to one SQL statement by core/aggregation-translator.ts: $match β†’ WHERE through the adapter’s own query mapper (JSON fields, containment and tenant scope behave exactly like find*), $group β†’ GROUP BY ($sum/$avg/$min/$max/$count, scalar _id, dotted paths, $sum: 1 β†’ COUNT(*)), $sort/$skip/$limit β†’ ORDER BY/OFFSET/LIMIT, $count/include-only $project β†’ the select list. Anything else ($lookup, $unwind, $facet, compound _id, out-of-order stages) returns a named NOT_SUPPORTED refusal β€” returned outside wrap(), because an envelope returned inside it reaches the caller as { success: true, data: { success: false, … } }. $min/$max are engine-dependent by nature: on a non-materialized field PostgreSQL/MariaDB refuse (TEXT extraction is lexicographic β€” MIN of {10, 2} is "10", and PostgreSQL has no min(jsonb)), SQLite (typed json_extract) and MongoDB answer natively, and a materialized column works everywhere. supportsAggregation is true on the three SQL engines (documented subset). | | Dynamic-field range guard (PostgreSQL) | PostgreSQL | $gt/$gte/$lt/$lte on a blob field: the extraction (->>) is TEXT, so the comparison was lexicographic β€” views >= 4 missed stored 16/32. Comparing the JSON value instead is numeric, but jsonb orders String below Number, so an unguarded < admitted a stored "101" into views < 4 (verified: '"101"'::jsonb < '4'::jsonb is true). Emitted form: jsonb_typeof(data->'views') = 'number' AND data->'views' >= '4'::jsonb β€” numeric, no cast error on non-numeric data, and type-strict in both directions like SQLite/MongoDB. SUM/AVG on blob fields cast through getJsonNumericField (CAST(… AS numeric)). | | Dynamic-field filter index (PostgreSQL) | PostgreSQL | Equality filters on fields that were NOT materialized into columns are translated to containment (data @> '{"field": value}'::jsonb, getJsonEquals) and served by a jsonb_path_ops GIN index that createModel now provisions for every dynamic collection table (<table>_data_gin). Measured 2026-09-22, 100k rows: extraction (data->>'field' = $1) Seq Scan 7.9–10.9 ms β†’ containment Bitmap Index Scan 0.1–1.7 ms, and the GIN cost stops growing with table size; index build 347 ms once, write cost within run-to-run noise (interleaved rounds: βˆ’0.9 % inserts, +5.4 % updates). Type-fidelity: a number probe matches a JSON number (SQLite and MongoDB already behaved that way; MariaDB’s JSON_UNQUOTE comparison coerces and stays looser). SQLite/MariaDB keep the extraction form β€” neither has one index that serves arbitrary JSON paths, so their native answer is column materialization (indexed / materialize: true). Opt out per collection with jsonIndex: false. Guarded by tests/integration/databases/dynamic-field-filter-index.test.ts (semantics on all four engines + EXPLAIN proof on the adapter’s own SQL). | | Row-store hybrid (scalar fields β†’ columns) | All SQL | SELECTIVE materialization: only fields with a query benefit become physical columns β€” indexed/unique fields (real constraints + indexed filters/sorts) or an explicit materialize: true opt-in, always scalar-shaped (allowlist in drizzle-sql-helpers). All other fields stay in the data blob, keeping rows narrow on network adapters (every extra column costs a bind on writes + a decode on reads; measured PG INSERT +51% / FIND MANY +99% when ALL scalars were materialized, while unindexed columns only served filters). Filters/sorts on materialized fields use real columns/indexes; the read merge treats columns as authoritative (data fills only gaps); legacy rows are backfilled from data on column creation (idempotent UPDATE); raw findById selects materialized columns; boolean columns coerce 0/1 β†’ true/false on the raw paths; atomicIncrement targets the column when materialized. Also fixed a pre-existing silent ALTER no-op (PRAGMA ran through stmt.run()), PG/Maria dead columns (createModel made them; getTable never registered them), and the stale-materialized-column drift class (createModel never DROPPED columns/indexes from earlier full-materialization runs β€” dead indexes measurably slowed writes; tables rebuilt under the selective policy recovered the BULK INSERT regression) |

| Phase 1 (2026-09): Direct-to-Wire Streaming (sql-adapter-core.ts findPointWireStream) | SQLite (bun:sqlite/node:sqlite), PostgreSQL, MariaDB | Pre-compiled SELECT json_object(…) / json_build_object(…) statements return DB-native JSON Buffer; result is written directly to ReadableStream as pre-encoded bytes, bypassing JSON.parse + JSON.stringify. Point reads are routed from the read lane with requirePublished (the engine compiles status = 'publish' when the caller is publication-clamped and fails closed otherwise); the list-stream reader was removed as dead code, so a compiled list contract stays deferred. MongoDB builds the same envelope in JS from a driver-level findOne β€” no engine-side JSON generation exists there, so it avoids Mongoose hydration, not serialization. Saves 1-2 allocations per field per response. Byte-for-byte parity for point reads is pinned in tests/integration/databases/differential-parity.test.ts and tests/integration/databases/db-interface.test.ts. | | Phase 2 (2026-09): 64-Bit Bitmask Security Engine (databases/auth/permission-bitmask.ts) | Auth / RBAC Layer (all engines) | Maps all canonical endpoint permissions (43 bits, bits 0-42) to a fixed BigInt bitset. Session permMask is computed at createSession time, stored as a hex string, and deserialized as locals.permMask: bigint by handleAuthentication. _checkEndpointPermission evaluates (mask & bit) !== 0n β€” a single CPU register AND with zero heap allocations vs. the previous O(rΓ—p) role-loop. Dynamic/plugin permissions use bits 43-62; bit 63 is a write-only presence indicator for field:* grants (no authorization path reads it β€” FLAC enforcement is string-based). Cross-worker invalidation via SharedArrayBuffer + Atomics.compareExchange version counter. | | Phase 3 (2026-10): QueryBuilder Prepared Compilation & Covering List Indexes (sql-query-builder.ts, postgresql/adapter-core.ts) | SQLite, PostgreSQL, MariaDB | Compiles physical column equality (.where()), physical ORDER BY, and pagination into cached driver-prepared statements, bypassing dynamic Drizzle AST rebuilds on every call. On PostgreSQL, lists sorted by physical columns (e.g. createdAt DESC) asynchronously schedule a covering index (tenantId, column, _id) or (column, _id) off the pool without stealing worker connections or triggering close-handler reconnects. Measured on PostgreSQL (2,000 rows): admin default list drops from 4.88 ms to 0.85 ms (5.7Γ— faster), achieving full parity with raw findMany. | | Phase 4 (2026-10): True Statement Coalescing & Adaptive Burst-Hold Window (statement-coalescer.ts, postgresql/adapter-core.ts) | PostgreSQL (SQL multi-row UNNEST arrays) | Coalesces concurrent single-row writes for the same collection into single multi-row INSERT and UPDATE ... FROM UNNEST(...) statements over PostgreSQL arrays. Per-op fault isolation replays failed batches individually. INSERT coalescing (default ON via SVELTY_WRITE_COALESCING): +12–18% create throughput (2,216 β†’ 2,624 RPS) with improved p95. UPDATE coalescing with Lever 2 adaptive burst-hold window (opt-in SVELTY_WRITE_COALESCE_UPDATE=1, SVELTY_COALESCE_UPDATE_WINDOW_MS=1): +11–15% update throughput gain (update/create ratio 1.17–1.18 vs 1.02–1.07 baseline) on the 100k-doc harness by growing batches to 8–16 rows to amortize VALUES joins, while lone writes still flush on the tick (0ms artificial hold). Profile-dependent β€” two paired A/Bs on the 10k-doc matrix replica (2026-10-09) measured βˆ’41–54 % on the update lanes, so the lever stays opt-in and must not be defaulted at the matrix profile. |

| LocalCMS and HTTP turbo sit above this layer β€” same gains on every adapter:

SDK write-path slice Before After Note
Lazy module singletons (workflow/response-cache/pub-sub/outbox) 3.5k create / 3.4k update 4.0k / 6.3k per-write await import() cost 30–60Β΅s each β€” resolved once, hot path = promise resolves
Plugin-registry views (getAll() / hook presence) allocates mapped array + hook walk per mutation O(1) cached (invalidated on register) called twice per SDK create/update by triggerLifecycleHook
Outbox emit (buffered) crypto UUID + getDb() per emit crypto UUID stamped at emit time (~1Β΅s); trailing 25ms debounce (reset per emit) + hard-max 512 via setImmediate β€” write bursts become one insertMany, never a same-tick SQLite mutex steal measured 28Β΅s β†’ ~5Β΅s; full-pipeline create tax vs adapter ~11%
Audit hook (after-insert) logs every insert incl. outbox flush batches skips svelty_outbox (internal machinery); sync-first flags; skipReturning bulk flush the outbox→audit cascade (1000-entry flushes on the write path) was the hidden tax
Full-pipeline SDK create (SQLite) 2.7k RPS 3.7–7.6k RPS outbox delta vs disabled: +121.8% β†’ +22–75%; detached 10–14.4k (host-load dependent; range measured on the isolated benchmark DB)
Note

Benchmark DB isolation (2026-08): local benchmarks used to silently fall back to the live default name (config/test-database/sveltycms.db) because loadPrivateConfig returns null without DB_HOST and the SQLite adapter then guessed a filename. Benchmarks wrote into the same growing file across runs (38k SdkVsDirect rows, 380k benchmark_crud rows), which made every later run measure a bigger table β€” earlier sub-4k SDK-create readings were this pollution, not a regression. Fixes: benchmark harness sets DB_HOST explicitly; the SQLite adapter now fails closed in test/benchmark mode when no DB_NAME is derivable (never silently uses sveltycms.db); config/database/ remains the only live-data folder.

HTTP turbo / LocalCMS β†’ ICrudAdapter.findOne|insert|update
  β†’ SqlAdapterCore (SQLite | Postgres | MariaDB)
  β†’ MongoCrudMethods (MongoDB)

Core ↔ adapter communication (all 4 engines) {#coreβ€”adapter-communication-all-4-engines}

Every request β€” LocalCMS SDK or HTTP β€” uses the same write pipeline. The database adapter is a direct in-process call, not an HTTP hop:

  1. Valibot sanitization (once, in core).
  2. Cached schema + widget transforms (fields._activeWidgets, reused accessor).
  3. db.crud.insert / update / find on the active adapter.
  4. Single-statement native driver execution (SQL UPDATE … RETURNING / Mongo findOneAndUpdate $set).
  5. Off-path microtasks: outbox, L2 invalidation, webhooks, audit.

Adapters do not re-validate payloads (Mongo runValidators: false on hot writes). createdAt is insert-only on SQL and Mongo. Partial SQL updates omit an empty data={} blob.

BENCHMARK is not a write-path cheat. isBenchmarkExternalServicesDisabled() only skips outbound webhooks, SMTP, and similar network I/O so the matrix does not contact third parties. Persistence, Valibot, RBAC, RETURNING/$set, and L1 invalidation run the production path. skipSideEffects: true is an explicit SDK option (bulk seed / import), reported as a separate (detached) column β€” it is not toggled by BENCHMARK. Ambient BENCHMARK was removed from SQL skipReturning so insert/update benches measure the same SQL as production.

Layer What stays on the response path What is detached
Core schema / widgets In-memory LRU + field-cached transforms β€”
Adapter write One prepared statement / atomic $set β€”
Session (warm) LRU / turbo context β€” 0 DB round-trips β€”
Session (cold) auth.validateSession (sessionβ‹ˆuser JOIN); no fall-through to two extra queries L2 session cache fill
Mutation HTTP 200 Surgical L1: drop written doc: + lists/GraphQL; sibling findById turbo hits stay L2 tag clear (void / 10ms batch)
Boot Topological plugin boot in db-init.ts with zero settling delay FULL phase (cache warm, jobs) β†’ WARMED

LocalCMS collections hot path (create / update / findById)

Slow SDK entry paths historically paid for work that was not needed on simple schemas. Current design (CollectionsNamespace in src/services/sdk/namespaces/collections-namespace.ts):

Optimization Effect
Schema hot flags (_hasActiveWidgets, _hasNumberFields, _hasSanitizableFields, _hasHooks, _hasEncryptedFields) Skip field walks, hooks import, modifyRequest, and AES-256-GCM when unused
findById single-id β†’ crud.findOne Avoid $in findMany + full batch machinery cost for one id
findMany pure-_id β†’ findById (after before-hooks) List path with an id filter pays raw-SELECT speed (~10Γ—) instead of dynamic SQL
SDK find({ _id }) β†’ findById Collections find() with a pure id filter returns { data: [row] } via the single-id path (no findMany / coalesce / list cache key)
Warm peekReadySchema Cached schemas skip the await getSchema() microtask on find/create/update/delete
Same-tick batch window for concurrent findById Still coalesces N+1 without always using multi-id queries
HTTP list / GraphQL serve-stale Writes mark list/res:graphql turbo bodies stale instead of deleting them; the next GET/query serves the previous bytes. GET does not find()-refill on the request thread (response-cache.ts, collection read lane, GraphQL +server.ts)
Sync FNV query-hash for list cache keys No async hash-wasm on every find()
Static CacheCategory import No dynamic import on cache set
Entry mutations do not bump contentStore.contentVersion Avoids nav/SSE structure invalidation on every write
Path + alias indexes on contentStore O(1) getNodeByPath / collection alias lookup
Workflow definition negative cache (60s) Create path skips DB miss for collections without workflows
Parallel afterMutation + afterSave Write post-hooks no longer serialize
Outbox kill-switch pre-check before dynamic import Write microtask skips the outbox module import when DISABLE_OUTBOX=true (the only opt-out; ambient benchmark env toggles removed)
Lazy module singletons on the write path Per-write await import(workflow/response-cache/pub-sub/outbox) cost 30–60Β΅s each even when cached; resolve once, hot path becomes a promise resolve β€” full-pipeline create 3.5k β†’ 4.0k RPS, update 3.4k β†’ 6.3k RPS
Deduped cache-invalidation patterns Per-write pattern fan-out cut from 9 clears to 6 (identical generated keys)
Field-cached widget transforms (modify-request.ts) Active modifyRequest widgets resolved once onto fields._activeWidgets; reused accessor + context β€” no per-field spread/closure alloc
Document writes skip schema-structure cache keys post-write.ts no longer purges cms:content_structure on entry mutations β€” getSchema() stays an in-memory LRU hit
createdAt immutability on UPDATE SQL prepareValues + Mongo $set omit createdAt on updates; partial SQL updates skip empty data={} blobs
Mutation cache invalidation off the response path handle-api-requests L1 walk is sync; L2 deletes/pattern scans are fire-and-forget / 10ms-batched so they never add to 200 latency
Cross-chunk service singletons (globalThis) responseCache, webhookService, automationService share one instance across Rolldown chunks β€” invalidations apply immediately

In-Memory Plan & Metadata Caching: WeakMap vs. Standard Map {#weakmap-vs-map-security-analysis}

The CMS engine uses WeakMap extensively across hot paths (e.g. _dateKeyCache, prepPlanCache, collectionModelCache, and WAF request header inspection memoization).

Architecture & Mechanics Comparison

Characteristic Standard Map<K, V> WeakMap<K, V> Architectural Impact in SveltyCMS
Key Reference Type Strong (prevents Garbage Collection of key) Weak (key reference does not prevent GC) WeakMap allows dynamic collection reloads, schemas, and plugin teardowns to be collected cleanly.
Memory Lifecycle Manual cleanup required (.delete(), .clear(), or LRU eviction) Automatic (entry vanishes when key object is collected) Zero memory leak surface. Impossible to cause memory-growth DoS via dynamic schema or plugin creation.
Enumerability Iterable (.keys(), .values(), for...of, JSON.stringify) Non-iterable (no enumeration or size inspection) Keys and values cannot be inspected, enumerated, or dumped via heap reflection or prototype pollution.
Key Types Any primitive or object (string, number, object) Objects only (object, Function, symbol where supported) Used for object-anchored runtime metadata (table definitions, AST plans, request instances).
Lookup Cost $O(1)$ amortized hash table lookup $O(1)$ amortized engine-level ephemeron lookup Identical sub-microsecond hot-path performance without LRU pruning overhead.

Security Analysis: Does WeakMap Compromise Security?

No. In fact, WeakMap enforces a stronger security posture than standard Map:

  1. Denial of Service (DoS) & Memory Leak Mitigation: Standard Maps holding cached query plans or schema metadata require explicit eviction policies (LRU/TTL). Under tenant churn, frequent plugin hot-reloading, or dynamic collection generation, a standard Map risks unbounded memory leaks, eventually triggering process out-of-memory (OOM) crashes. With WeakMap, as soon as a schema definition or table instance goes out of scope, its associated cache entries are immediately reclaimable by the V8/JavaScript engine garbage collector.
  2. Structural Information-Hiding & Anti-Introspection: Because WeakMap does not expose .keys(), .entries(), or .size, an attacker gaining arbitrary code execution or prototype access cannot dump or enumerate cached database table metadata or query structures. Access to the cached value strictly requires holding a direct reference to the exact key object.
  3. Tenant & Data Isolation: WeakMap in SveltyCMS is used strictly for structural engine plans (e.g. date-column names, column selection sets, compiled sanitization functions), never for tenant data, user records, or cryptographic secrets. Data isolation remains strictly governed by tenantId boundaries in the database adapters and middleware.

Lean Payload Evolution: Smart Minimal Payloads for 2027 {#smart-minimal-payloads-2027}

In traditional REST APIs, write operations (POST / PATCH) echo the full persisted document back to the client. In SveltyCMS, a typical document with default schema fields, audit metadata, and relations occupies 1.9 KB to 3.5 KB. For high-throughput services, IoT ingest, and automated pipelines, parsing and transferring this discarded representation wastes over 95% of bandwidth and CPU cycles.

Two Activation Vectors: RFC 7240 Header or Query Projection

SveltyCMS provides zero-overhead minimal responses via two non-breaking mechanisms:

  1. RFC 7240 Standard Header:
    Prefer: return=minimal
  2. Smart URL Query Projection (2027 Wire Convenience):
    POST /api/collections/articles?fields=_id
    PATCH /api/collections/articles/123?return=minimal
    POST /api/collections/articles?fields=none

Write Execution Paths

Client Request Mode DB Read-Back (RETURNING / findOne) Wire Payload Size Wire Payload Example Status Code
Default (no header/param) Executed (re-reads merged entity) ~1.9 KB – 3.5 KB Full document representation 201 (create) / 200 (update)
Minimal Return (Prefer or ?fields=_id) Skipped (skipReturning: true) ~43–70 Bytes {"_id":"0192847a-..."} 201 (create) / 200 (update)

By setting skipReturning: true directly in the database adapter call, the database skips the RETURNING clause or secondary findOne fetch, eliminating both DB serialization cost and network transfer overhead while remaining 100% backward-compatible with legacy REST consumers.

HTTP findById turbo lane + responseCache

Authenticated GET short-circuit (handleTurboGet β†’ L1 responseCache):

Fix Why
successResponse stashes apiBody Turbo/write path can re-use one stringify
Weak-ETag path still writes responseCache Collections findById used weak ETag and previously never warmed turbo L1
Collection-generation 304 (COL-304) If-None-Match on GET /api/collections/{id} compares a weak cv1 epoch tag to the in-memory epoch before DB/JSON (turbo + API-read hooks)
Size-gated generateContentEtag Bodies up to 1 KiB keep FNV-1a 64-bit. Larger bodies use a native SHA-256 digest prefixed s256. Measured 8 ms to 0.1 ms on a 380 KB list (2026-09-28, compression A/B)
Real session turbo warm Benchmark logins populate turboAuthCache via the real auth pipeline β€” turbo fires with real cookies (benchmark pseudo-sessions removed 2026-08)
API cache writes use buildUserResponseCacheKey Same key as handleTurboGet (was writing only cacheService under a different key)
# Rebuild required before HTTP turbo numbers pick up hook/handler changes
bun run build
bun test tests/benchmarks/api-latency.test.ts   # cold (cache-busted) + TURBO-HIT
bun test tests/benchmarks/local-cms-crud.test.ts

When enterprise auth extras are disabled/optional: middleware still enforces core auth + RBAC for protected routes; SAML/SCIM/2FA/passkeys add cost only when configured (see Authentication System).

How to re-baseline after changes:

BENCHMARK_RECORD=1 bun test tests/benchmarks/database-performance.test.ts
bun test tests/benchmarks/local-api-throughput.test.ts
bun test tests/benchmarks/api-latency.test.ts
bun test tests/benchmarks/local-cms-crud.test.ts
bun test tests/benchmarks/mixed-workload.test.ts
bun run test:unit -- tests/unit/services/local-cms.test.ts

Middleware & Request Lane Pipeline (Verified August 2026)

Every request is classified in O(1) by classifyRequest() (src/hooks/request-classifier.ts) before the middleware sequence. Responses carry x-svelty-lane for observability and load-balancer routing.

Request Lane Latency RPS
Turbo Pipeline (Light) 0.294 ms 3,130
Full Security + Auth 0.884 ms 962
REST with API Caching 0.856 ms 1,029
GraphQL Query Caching 0.865 ms 1,018
Health Check Fast Path 0.572 ms 1,749
Static Asset Fast Path 0.586 ms 1,524

What each lane does

Lane When Pipeline cost
FAST_STATIC /favicon.ico, /robots.txt, /_app/*, /static/* Skip full stack; long-cache headers
HEALTH /health, /api/system/health Immediate JSON health (no auth/DB wait)
HYPER_TURBO Authenticated GET/HEAD on cacheable API prefixes + warm session handleTurboGet may serve pre-stringified body + ETag
API_READ Public/unauthenticated GET API, or non-cacheable GET Full security + auth path
API_WRITE POST/PUT/PATCH/DELETE Full CSRF + RBAC + mutation pipeline
APP_SSR Admin pages (/dashboard, /config, /collections, … + locales) Full SSR pipeline. /dashboard hydrates layout + widget.json picker in load(); widget Svelte chunks stay lazy.
BOOTSTRAP /setup, /login, /auth Setup/auth shell
FILES /files/*, /media/* downloads File server path
PUBLIC_SITE Non-admin public pages Public SSR

Cacheable API prefixes (eligible for HYPER_TURBO when a session cookie or Authorization is present): /api/collections, /api/content, /api/settings, /api/system, /api/graphql, /api/media, /api/dashboard, and related admin namespaces listed in CACHEABLE_PREFIXES.

GraphQL / REST response cache (operator view)

Path Warm hit (typical) What is skipped on hit
HTTP GET (Turbo lane) ~0.25–0.35 ms Full handler body recompute; uses user-scoped response cache
HTTP POST GraphQL (handler) ~0.45–0.65 ms Yoga re-parse / JIT cold cost when L1 has body+etag tuple
LocalCMS in-process ~0.016 ms No HTTP; still zero-tax vs direct adapter

Cache keys are user-scoped (u:{userId}:…) with deep-sorted GraphQL variables so multi-tenant / multi-user isolation holds. Mutations invalidate via responseCache (res:* / GraphQL patterns).

Tests (pyramid):

Layer File Contract
Unit tests/unit/core/request-classifier-and-response-cache.test.ts All lanes + auth_sessions turbo detection + GraphQL key isolation
Unit tests/unit/hooks/handle-turbo-get-lane.test.ts TURBO-HIT vs miss, Host cookie, POST skip
Integration tests/integration/api/request-lane-headers.test.ts Live x-svelty-lane + health JSON + dashboard __data.json
E2E tests/e2e/routes/system/hooks-lane-smoke.spec.ts Admin session shell + /health browser request
Bench tests/benchmarks/lane-router-attribution.test.ts Classification throughput

Cross-Database Impact (All 4 Engines)

Optimization SQLite PostgreSQL MariaDB MongoDB
Schema-aware row conversion βœ… Full βœ… Full βœ… Full N/A (own path)
Ring-buffer result pool βœ… Full βœ… Full βœ… Full βœ… Full
Conditions array pool βœ… Full βœ… Full βœ… Full N/A (own path)
Fused mapQuery (no IR objects) βœ… Full βœ… Full βœ… Full βœ… Full
for…in (zero Object.entries) βœ… Full βœ… Full βœ… Full βœ… Full
Pre-allocated meta object βœ… Full βœ… Full βœ… Full βœ… Full
MariaDB double-parse isolated N/A βœ… Benefit βœ… N/A
Parallel list + count (websiteTokens.getAll) βœ… Full βœ… Full βœ… Full βœ… Full
Prepared-statement cache βœ… N/A (driver caches) N/A (driver prepared) N/A
Projection (fields β†’ skip data blob) βœ… βœ… βœ… βœ… (driver)
Single-statement deleteMany βœ… βœ… βœ… βœ… (native)
Raw UPDATE…RETURNING write fast path βœ… βœ… (Drizzle) βœ… βœ… (findOneAndUpdate)
Skip driver re-validation on hot writes N/A N/A N/A βœ… (runValidators:false on update/upsert β€” Valibot already ran at the SDK/API layer; restore keeps validators for unique-collision detection)
Targeted column UPDATE (omit empty data) βœ… βœ… βœ… βœ… ($set only provided fields)
createdAt insert-only βœ… βœ… βœ… βœ… (stripped from $set)
postgres.js JSON/array param bind N/A βœ… (::jsonb) N/A N/A
Optional Redis L2 (PubSub sync + TCP_NODELAY) βšͺ Optional πŸš€ ~32% p95 drop πŸš€ ~33% p95 drop πŸš€ ~20% latency drop

SQL family gets ~5-8 fewer allocations per filtered query (self-assessed estimate β€” not instrumented). MongoDB gets ~2-3 fewer (result pool + fused mapQuery) plus no per-document validation re-run on update/upsert. All 4 databases benefit from shared BaseAdapter optimizations. Redis L2 is strictly optional across all 4 adapters β€” when absent, in-memory L1 cache handles hot reads at <0.001ms. Paths that promise no allocation are pinned by reference-identity assertions (tests/unit/core/relational-utils.test.ts, tests/unit/security/safe-query-manual.test.ts).

Credential list hot-path

system.websiteTokens.getAll() keeps admin tables sub-millisecond under multi-tenant filtering ({ tenantId, name } compound index):

  • MongoDB β€” crud.findPage with limit+1 hasMore + total: "exact" (count goes through the 30s L1 wrapper)
  • SQL engines β€” parallel SELECT … LIMIT n+1 + COUNT(*) (Promise.all in relational-system.ts); sentinel row trimmed before scrubbing hashes

Product-layer list & count (equal on all engines) {#product-layer-listβ€”count-equal-on-all-engines}

Design rule: Prefer cheaper correctness tiers by default. Exact cardinality is opt-in. Optimizations live in the shared ICrudAdapter contract β€” not engine-specific call sites β€” so SQLite, PostgreSQL, MariaDB, and MongoDB gain together.

findPage + hasMore (limit + 1)

Admin and API list UIs no longer need findMany + count for β€œnext page?” semantics.

const page = await db.crud.findPage(
  "posts",
  { status: "publish" },
  {
    limit: 50,
    tenantId,
    total: "none", // default β€” no COUNT(*)
  },
);
// page.data = { items, hasMore, pageSize, nextCursor? }
Option Behavior
total: "none" (default) Single query with limit + 1; hasMore from row count
total: "exact" Adds exact count after the page fetch
total: "estimate" / "auto" Adds approximate total when safe (see count modes)

Implementation: SqlAdapterCore.findPage, MongoCrudMethods.findPage, pure helpers in core/page-utils.ts. Tenant guard forwards findPage. Schema proxy: db.posts.findPage({ filter, limit, cursor }).

Keyset cursor (deep pages)

nextCursor is an opaque base64url payload (id + optional sort field/value + direction). Pass it as options.cursor on the next call:

  • Ignores offset when cursor is set (avoids OFFSET N scans)
  • Filter merge uses Mongo-style $lt/$gt/$or/$and β€” SQL mapQuery and Mongo both understand them
  • Default sort when omitted: { _id: -1 } for stable keyset order
  • Tiebreak follows the sort direction: the _id tiebreak appended to a paginated ORDER BY/sort() runs in the same direction as the sort key it follows (createdAt DESC β†’ _id DESC), so the composite stays monotonic and one index can serve it (a backward scan covers the same-direction keyset indexes); a mixed-direction composite (createdAt DESC, _id ASC) is served by no B-tree and forces a filesort / blocking SORT stage
  • Direction spellings are normalized per engine: the wire form ("asc"/"desc") and the Mongo-style numeric form (1 ascending / -1 descending, as emitted by defaultPageSortOption) produce the same clause everywhere β€” normalizeSortDirection() in core/page-utils.ts is the single definition
  • Legacy plain _id strings still decode as cursors

When total is requested, findMany and count run in parallel (count still hits the 30s L1 cache).

Unified count(..., { mode })

Mode When Semantic
exact Billing, RBAC, uniqueness True cardinality
estimate Dashboards / badges when unfiltered Engine stats / metadata
auto (default) General Estimate if empty filter + no tenantId; else exact

Estimate is never used for tenant-scoped filters (would leak whole-table cardinality). Eligibility: shouldUseEstimateCount() in core/page-utils.ts.

Engine Estimate path
MongoDB estimatedDocumentCount() (WiredTiger metadata)
PostgreSQL pg_class.reltuples
MariaDB / MySQL information_schema.TABLES.TABLE_ROWS
SQLite sqlite_stat1 when ANALYZE present; else exact (already sub-ms)

Short-lived tenant count cache (L1 / L2)

createCountCachedCrud (core/cache-module.ts) wraps crud.count after the tenant guard in db.ts:

  • TTL: 30 seconds (COUNT_CACHE_TTL_SECONDS)
  • Key: count:{collection}:{mode}:{includeDeleted}:{filterHash} (tenant-namespaced by cacheService)
  • Tags: count, count:{collection}, collection:{collection} β†’ cleared with invalidateQueryCache
  • Bypass: options.bypassCache: true

Equal benefit: after the first miss, repeated admin badge / dashboard counts hit L1 (~0.024 ms on all engines in the 2026-08-04 re-baseline).

Verified gains (2026-08-04 re-bench, local Docker / embedded)

Suite: BENCHMARK_RECORD=1 bun test tests/benchmarks/database-performance.test.ts
Hardware: same host as prior adapter baseline. Redis disabled. Includes keyset scenarios.

Scenario SQLite PostgreSQL MariaDB MongoDB
FIND PAGE (50 hasMore) 0.086 ms 1.008 ms 0.664 ms 0.867 ms
LIST+COUNT (legacy) 0.192 ms 2.233 ms 1.086 ms 4.925 ms
findPage vs legacy ~2.2Γ— ~2.2Γ— ~1.6Γ— ~5.7Γ—
FIND PAGE keyset (page 2) 0.072 ms 0.996 ms 0.645 ms 0.748 ms
FIND MANY offset 50 0.050 ms 0.904 ms 0.585 ms 0.705 ms
COUNT exact (filtered, no cache) 0.120 ms 2.197 ms 0.936 ms 3.817 ms
COUNT ESTIMATE (unfiltered) 0.023 ms 0.745 ms 0.502 ms 0.492 ms
COUNT CACHED (L1 hit) 0.024 ms 0.024 ms 0.029 ms 0.025 ms
estimate vs exact ~5Γ— ~3Γ— ~1.9Γ— ~7.8Γ—
cached vs exact ~5Γ— ~90Γ— ~32Γ— ~150Γ—
Note

Networked engines benefit most from dropping the second round-trip (LIST+COUNT β†’ findPage) and from count cache. Keyset vs shallow OFFSET 50 is near-parity; prefer cursor for deep pages (OFFSET 10k+) where cost grows with N.

getCollectionData now uses findPage with total: "auto" only when includeMetadata is true; list-only paths skip count entirely.

PostgreSQL raw-path identifier quoting fix (2026-08-11)

Bug: the PG adapter’s raw findById and raw UPDATE…RETURNING paths interpolated the table name unquoted (exec.unsafe(getTableName(table))). PostgreSQL folds unquoted identifiers to lowercase, so any mixed-case collection table (e.g. collection_BenchmarkStable) raised 42P01 relation does not exist on every call and silently fell back to the slower Drizzle path β€” the raw fast paths had never engaged for mixed-case collections (the only kind the content system creates). MariaDB/SQLite were unaffected (backticks / case-insensitive identifiers).

Fix: quote the identifier in both raw paths ("${name.replace(/"/g, '""')}").

Measured (honest production server, real session, Docker PostgreSQL, tmp-profile probe):

Cell Before After
HTTP update (client-observed) 6.21 ms 4.34 ms (βˆ’30%)
Update handler + adapter (ns:persist) 3.1–5.9 ms 1.7–2.5 ms
Random read (cache miss) 0.70 ms 0.57 ms

Reads/writes on mixed-case collections now use the prepared raw statements (parse-once + bind/execute reuse) exactly like lowercase test collections did.

Philosophy

SveltyCMS implements best-practice performance patterns from day one. Every feature is built with performance as a core requirement, not an afterthought. The 2027 allocation-floor work pushes the database layer to the practical limit of the current architecture (Drizzle ORM + DatabaseResult contract). Product-layer list/count contracts (above) reduce round-trips before that floor matters.

2027 Allocation-Floor Optimizations

Row conversion & schema registration

Schema-aware row conversion. Instead of checking every row key against DATE_FIELDS/JSON_FIELDS Set lookups on every row read/write, table schemas are pre-computed once during table creation. Only known date and JSON columns are converted β€” eliminating per-row Set.has() calls and enabling targeted iteration.

// Before: checks every key against Sets
for (const k in row) {
  if (DATE_FIELDS.has(k)) {
    /* convert date */
  } else if (JSON_FIELDS.has(k)) {
    /* parse JSON */
  }
  result[k] = row[k];
}

// After: only touches known columns
registerTableSchema("posts", ["_id", "title", "createdAt", "metadata"]);
// converts only "createdAt" (date) and "metadata" (JSON) β€” skips everything else

Registrations are additive β€” knowledge only grows: a later registerTableSchema call that includes materialized/boolean columns augments an earlier base-only registration (and a later partial registration never shrinks the maps). Both the physical (collection_…) and logical table names are registered so raw and Drizzle paths share the same schema knowledge.

Canonical collection naming (2026-08-20). All physical collection table/model names derive from one function β€” normalizeCollectionTableName() in src/databases/core/collection-name.ts (prefix-aware, hyphen-stripping, idempotent). Consumers: the SQL adapters’ getTable()/_warmTableRegistry()/createModel cleanup, createIndexes() (DDL), registerTableSchema() variant keys, MongoDB’s normalizeCollectionName()/createModel()/deleteModel(), the Local SDK, and API handlers. This closes two latent hyphenated-id mismatches: SQL CREATE INDEX previously targeted a phantom table name, and MongoDB previously registered two different Mongoose models (full-schema vs generic) for the same hyphenated collection.

MariaDB double-parse isolation. MariaDB’s native JSON columns can double-encode. The double-parse loop is now opt-in (mariaDoubleParseJson: true) β€” PostgreSQL and SQLite paths never pay this cost.

Prepared statements & caches

SQLite INSERT/UPDATE templates (2026-08-23). SQLite rawInsertReturning now matches PostgreSQL: one cached INSERT … VALUES (?) per table/column-set, no RETURNING *, row synthesized from bound values (CMS tables have no insert triggers). UPDATE SQL is cached per (table, column-set, tenant clause, skipReturning) so the SET list is not rebuilt on every PATCH.

SQLite RETURNING cache. The insertMany path caches whether RETURNING is supported per SQLite instance. Avoids repeated try/catch + logger.warn overhead in benchmarks and unsupported table scenarios.

QueryBuilder list conversion (2026-08-23). Admin collection lists use queryBuilder().execute(), not crud.findMany. That path registers the table schema and converts rows in place (same maps as findMany) so MariaDB/PostgreSQL/SQLite lists do not allocate a new object per row or walk unknown keys. As of 2026-10-06, execute() and count() compile equality filters on physical columns, a physical ORDER BY, and limit/offset into one cached SQL string and run it on the driver statement cache (postgres.js prepare, mysql2 execute, SQLite prepare). JSON search and multi-operator ranges stay on Drizzle; physical OR groups and $and-wrapped keyset filters compile too. The first paginated sort on a physical column other than updatedAt schedules a covering list index β€” (tenantId, column, _id) when the filter equals tenantId, otherwise (column, _id) β€” so ORDER BY createdAt DESC, _id DESC is an index scan. SVELTY_LAZY_SORT_INDEXES=0 skips that index. exists() is SELECT _id … LIMIT 1 (Mongo: findOne + _id projection) instead of COUNT(*) / countDocuments. Mongo upsert is one findOneAndUpdate with upsert: true β€” _id stays on the filter so $setOnInsert never carries it (Mongoose 9 rejects _id there).

Allocation pooling

Ring-buffer result pool. All BaseAdapter.wrap() calls now use a 64-slot ring-buffer pool for { success, data } result wrappers. Pooled slots are safe in single-threaded JS β€” callers consume results synchronously after await. Eliminates 100% of result wrapper allocations.

Ring-buffer conditions array pool. Filtered queries (mapQuery) reuse conditions: SQL[] arrays from a 32-slot ring-buffer pool instead of allocating [] each time. Arrays are cleared via .length = 0 without deallocation.

Pre-allocated meta object. The { executionTime } meta sub-object is pre-allocated once per adapter. wrap() mutates this._meta.executionTime instead of creating a new object per call.

Hot-path bypasses

GraphQL Yoga bypass for catalog/health (2026-08-23). contentSystemHealth and allCollections are single-root in-memory queries. After auth, the GraphQL handler answers them without Yoga/JIT (field projection from the selection set). Shared parse cache so comment-busted response-cache keys still reuse the same DocumentNode for JIT on everything else.

Automation event→flow index (2026-08-23). AutomationService keeps a per-tenant event → active event-triggered flows Map, rebuilt on cache refresh, save, and delete. handleEvent does one Map lookup and returns immediately when nothing subscribes to the event — content mutations no longer scan every flow. An earlier SQLite micro-cache for every list shape was measured when a read was ~6 ¡s and did not move a REST request. The 2026-10-06 plan above is one prepared statement for the equality, order, and limit shape.

Zero-tax SDK dispatcher. Self-overwriting getters eliminate Proxy overhead after first access. Verified: 0.00% middleware tax (LocalCMS is statistically identical to direct adapter calls).

Fused Query Construction (SQL + MongoDB)

Both mapQuery implementations now build database conditions directly from user queries without intermediate IR (Intermediate Representation) objects. The old pattern (query β†’ QueryIR β†’ LogicalGroup β†’ QueryCondition[] β†’ SQL conditions) is replaced with a single fused walk.

  • SQL path (drizzle-sql-helpers.ts): addFilterConds + addSingleCondition build SQL[] directly
  • Mongo path (mongodb/adapter-core.ts): addMongoConds + addMongoCondition build filter objects directly
  • Eliminated: QueryIR, LogicalGroup, QueryCondition[] intermediates (~3-5 objects per filtered query)
  • Eliminated: All Object.entries() array allocations (replaced with for...in)

Structural Improvements (Shared by All Adapters)

  • Proxy extraction: 3 scattered Proxy factories β†’ 1 shared core/proxy-utils.ts with 23 dedicated tests
  • Tenant filter centralization: ~4+ duplicate sites β†’ single source of truth in relational-utils.ts
  • Query IR & wire-descriptor removal: standalone query-ir.ts (translator already inlined into the 2 real consumers) and wire-descriptor.ts removed as unreferenced
  • Mongo file reduction: 43 β†’ 39 files (4 thin module/model pairs merged)
  • Code reduction: 1,105 lines deleted across 36 files
  • Lazy Zero-Allocation GraphQL Loaders: Eager per-request user, media, and collection loaders are refactored into on-demand lazy getters, eliminating memory overhead on requests that do not query those fields.
  • Direct Primary Key Index Loading: Replaced MongoDB-style $in filter translations with explicit, database-agnostic db.crud.findByIds index lookups, allowing SQL and document database engines to resolve relational widget fields with zero query-parsing overhead.

Phase 1: Database Indexing & Optimization

Status: βœ… FULLY IMPLEMENTED Impact: Exponential query speedup (O(n) β†’ O(log n))

Index provisioning is schema-driven inside createModel β€” a collection’s indexed/unique fields, its tenant-scoped sort indexes (("tenantId", status, "updatedAt" DESC, "_id" DESC)) and PostgreSQL’s jsonb_path_ops GIN index for dynamic-field containment are created in the same call that creates the table. Dynamic sort fields get a lazy expression index on first use (CREATE INDEX IF NOT EXISTS ((data->>'field'))). MongoDB creates its compound/text indexes from the schema in MongoCollectionMethods.createIndexes. Multi-database support: MongoDB Compound/Text, SQL B-Tree.

Covering-composite policy (shared by PostgreSQL, MariaDB, SQLite)

The covering composite (tenantId, status, <col>, _id) β€” the index that serves WHERE tenantId=? AND status=? ORDER BY <col> DESC, _id DESC without a sort node β€” is provisioned only for declared query targets: fields marked indexed: true, numeric fields (number/integer, the default listFilterSort sort target), and the publishedAt base column. Equality-lookup base columns (collection, slug, locale) and storage-only columns (unique, materialize: true) keep their single-column index (still filterable) but no covering twin. The predicate is buildCompositeIndexColumns() / earnsCompositeIndex() in src/databases/core/drizzle-sql-helpers.ts, consumed by all three SQL engines so the provisioned index set is identical; legacy twins for ineligible columns are dropped explicitly, following the …_tenant_status_updated twin-drop precedent. The default list order is unaffected β€” it is served by …_tenant_status_updated_id, which is always created.

Why: every extra index is maintained on every write. updatedAt is indexed and rewritten by each update, so PostgreSQL can never take the HOT path and inserts into all indexes of the table. Measured (PostgreSQL, 2 000-row batches across alternating orders, 3 vs 4 indexes): +11–17 % per extra index (β‰ˆ 0.7 Β΅s/row/index); on the 113 k-row benchmark table the policy takes the set from 15 to 12 indexes.

What this does and does not buy. It is a write-amplification / WAL-volume reduction, not an HTTP-lane latency win: on Docker-on-Windows the update lane’s dbwrite phase is 75–85 % durable-commit wait (single-row autocommit UPDATE 1.57–4.50 ms with synchronous_commit=on vs 0.31–0.43 ms with off; 200 rows in one transaction = 42–44 Β΅s/row for all tuple+index work), so a single-digit-Β΅s index saving is invisible there. Guarded by tests/unit/databases/composite-index-policy.test.ts and Composite-index policy β€” {engine} in tests/integration/databases/dynamic-field-filter-index.test.ts (green on SQLite and PostgreSQL).

Batch Relational Upserts

setMany, content-node upserts, and crud.upsert / crud.upsertMany (when the filter is _id) use true multi-row INSERT ... ON CONFLICT DO UPDATE via Drizzle ORM. PostgreSQL/SQLite use excluded.*; MariaDB/MySQL use VALUES(). Non-PK filters still fall back to findOne + insert/update. Collection import (bulkImportCollectionDocuments) is findByIds + insertMany/upsertMany per 100-row chunk β€” not N findOne calls.

Negative Caching

O(1) missing-key cache prevents β€œDB miss storms” (verified 2392x speedup for repeated misses).

Prefix-Bucketed Invalidation

O(1) namespace clearing replaces O(N) cache scans.

Read-Replica Splitting & Read-Your-Writes (RYW) Horizontal Scaling

Horizontal read scaling via ReplicaRouterAdapter offloads read traffic (find*, count, aggregate) across an arbitrary number of read-only replica nodes while steering all writes (insert, update, delete, batch.*) to the primary:

  • 100% Read Offload: When read replicas are active, 100% of non-transactional read traffic is served by replicas via configurable load balancing (round-robin, least-connections, random).
  • Guaranteed Read-Your-Writes (RYW) Consistency: Tracks per-client write watermarks (readYourWritesWindowMs, default 2,000ms). When a client writes data, subsequent reads from that specific client route directly to the primary, guaranteeing fresh data and eliminating replication lag races.
  • Fail-Safe Circuit Breaker: Each replica node monitors errors; nodes exceeding 3 consecutive errors enter a 15s cooldown. Queries automatically fail over to the primary database with zero caller errors.
  • Zero Overhead on Single Node: When no replicas are configured, createReplicaRouterAdapter returns the primary adapter directly without any proxy layer or routing overhead.

Operator Guide: Further Server Performance Gains

SveltyCMS ships with strong defaults. Use this checklist on top of the lane/cache stack above β€” none of it requires forking core code.

1. Request lanes & application cache (CMS-level)

Goal What to do
Maximize Turbo GET hits Keep real session cookies (auth_sessions / __Host-auth_sessions / __Secure-auth_sessions) or Bearer tokens on cacheable GETs; anonymous traffic stays on API_READ and never uses turbo auth.
Warm GraphQL reads Prefer idempotent GETs with stable query + variables for public/catalog data; POST still caches when the handler warms responseCache, but GET turbo short-circuits earlier.
L2 shared cache across nodes Enable Redis (USE_REDIS / private config) so cacheService L2 + response-cache L2 are shared; without Redis, each node only has L1 Maps.
Avoid self-inflicted misses Content mutations already invalidate response cache; custom bulk scripts should call the same invalidation paths (or restart) so clients do not serve stale turbo bodies.
Observe lanes in production Log or sample x-svelty-lane at the reverse proxy; high API_WRITE or low HYPER_TURBO share usually means missing auth headers on GETs or traffic outside CACHEABLE_PREFIXES.
Health checks Point load balancers at /health (lane HEALTH) β€” not admin SSR β€” so probes stay sub-ms and do not warm auth/session paths.
LocalSDK vs HTTP Server-side code should use LocalCMS / Local SDK (documented zero-tax path). Never fetch('/api/…') from +page.server.ts for hot paths.

2. Database server tuning (engine-level)

SveltyCMS ships with zero-configuration defaults that work out of the box with any Docker Compose setup. For production or high-throughput workloads, the following database-level tuning tweaks can yield 2-10x latency improvements without changing any CMS code.

Tip

All tuning below is applied at the database server level (e.g., docker compose command overrides or my.cnf). SveltyCMS adapters adapt automatically β€” no CMS config changes needed.

MariaDB / MySQL

Setting Default (Docker) Recommended Why
max_connections 151 500–1000 Default ceiling easily exhausted under concurrent CMS workloads. Verified: Max_used_connections=152 exceeded the 151 cap, causing connection saturation and 1041ms HTTP latency spikes.
innodb_buffer_pool_size 128 MB 70-80% of available RAM Tiny default forces constant disk reads. For a 4 GB container: innodb_buffer_pool_size=3G. This is the single most impactful tuning knob for MariaDB performance.
innodb_log_file_size 96 MB 512 MB – 2 GB Larger redo logs reduce write contention and checkpoint pressure.
innodb_io_capacity 200 2000–5000 Modern SSDs can handle 50k+ IOPS. Default throttles InnoDB background I/O.
innodb_flush_log_at_trx_commit 1 (fsync per commit) 2 Setting to 2 avoids per-commit fsync (flushes once per second). Caution: trade 1-second durability window for faster writes β€” acceptable for most CMS workloads.
table_open_cache 2000 4000–8000 Reduces table open/close churn when many collections are active.

Measured gains with all tweaks (Intel i7-13700H, Docker, MariaDB 12.2.2):

Operation Default (avg / p95) Tuned (avg / p95) Improvement
INSERT 1.229ms / 3.061ms 0.791ms / 1.100ms -36% / -64%
DELETE 2.044ms / 2.664ms 0.665ms / 0.827ms -67% / -69%
BULK INSERT (100) 5.497ms / 10.170ms 4.460ms / 6.772ms -19% / -33%
TX Commit 4.268ms / 8.675ms 2.250ms / 3.869ms -47% / -55%
TX Rollback 3.016ms / 5.393ms 3.450ms / 6.188ms ~same
INSERT RPS 716 req/s 994 req/s +39%
DELETE RPS 428 req/s 1,293 req/s +202%
TX Commit RPS 225 tx/s 371 tx/s +65%

Read operations (SELECT, FIND) are minimally affected by tuning on small datasets because the default 128MB buffer pool is sufficient for light workloads. Gains compound significantly on production datasets (10k+ entries) and under concurrent multi-tenant load.

Docker Compose override example (docker-compose.override.yml):

services:
  mariadb:
    command:
      - --max_connections=500
      - --innodb_buffer_pool_size=4G
      - --innodb_log_file_size=512M
      - --innodb_io_capacity=2000
      - --innodb_flush_log_at_trx_commit=2

PostgreSQL

Setting Default (Docker) Recommended Why
max_connections 100 200–400 Default caps connection pool quickly. Beyond 400, add PgBouncer for connection pooling.
shared_buffers 128 MB 25% of available RAM Default is too small for active working sets.
effective_cache_size 4 GB 75% of available RAM Helps query planner choose index scans over seq scans.
work_mem 4 MB 16–64 MB Per-sort/join memory. Important for complex relational queries.
wal_buffers 4 MB 64 MB Reduces WAL write contention during bulk operations.
random_page_cost 4.0 1.1 Modern SSDs have near-zero seek cost. Lower value encourages index scans.
synchronous_commit on off Disables per-transaction fsync. Caution: 1-second durability window. Measured on this bench host (single-row autocommit UPDATE, 113 k-row table): 1.57–4.50 ms β†’ 0.31–0.43 ms β€” the durability wait is 75–85 % of the write lane’s dbwrite phase, so index/app-level Β΅s effects are invisible with on. Useful as a diagnostic profile (never a headline benchmark number).

Measured gains with all tweaks (Intel i7-13700H, Docker, PostgreSQL 18.3):

Operation Default (avg / p95) Tuned (avg / p95) Improvement
INSERT 2.201ms / 4.301ms 1.463ms / 1.850ms -34% / -57%
UPDATE 3.036ms / 4.437ms 1.424ms / 1.729ms -53% / -61%
DELETE 2.587ms / 3.112ms 1.200ms / 1.422ms -54% / -54%
NATIVE UPSERT 2.638ms / 3.208ms 1.235ms / 1.454ms -53% / -55%
TX Commit 4.620ms / 7.688ms 4.029ms / 6.843ms -13% / -11%
INSERT RPS 431 req/s 577 req/s +34%
UPDATE RPS 305 req/s 603 req/s +98%
DELETE RPS 333 req/s 703 req/s +111%
HTTP E2E RPS 810 req/s 999 req/s +23%

Write-heavy operations (INSERT, UPDATE, DELETE, UPSERT) benefit most from synchronous_commit=off and larger shared_buffers. Read operations see moderate gains from better query planning (random_page_cost=1.1, effective_cache_size=6GB). The HTTP E2E throughput improves by 23% as the cumulative middleware + DB pipeline becomes more efficient under load.

⚑ Application Environment Overrides for Low-Latency Development & Benchmarking

SveltyCMS supports native environment variable overrides to tune database session parameters directly from .env or process environment, eliminating the need to modify external database container files:

Environment Variable Target Database Default Fast Dev / Benchmark Setting Impact & Behavior
DATABASE_IDLE_TIMEOUT PostgreSQL 0 (disabled) 0 Seconds before closing an idle pooled connection. 0 keeps pool warm, eliminating connection storm spikes.
DATABASE_CONNECT_TIMEOUT PostgreSQL 10 (seconds) 10 Timeout in seconds when establishing a new connection to PostgreSQL.
MARIADB_IDLE_TIMEOUT MariaDB 0 (disabled) 0 Milliseconds before an idle pooled connection is released. 0 disables eviction, keeping connections warm.
MARIADB_MAX_IDLE MariaDB Same as pool max Same as pool max Maximum number of idle connections held open in the mysql2 pool.
MARIADB_KEEPALIVE_DELAY_MS MariaDB 0 0 Initial delay in ms for TCP keepalive probes.
MONGODB_IDLE_TIMEOUT_MS MongoDB 0 (disabled) 0 Maximum time in ms that a connection can remain idle before being closed. 0 keeps sockets permanently warm.
MONGODB_HEARTBEAT_MS MongoDB 10000 (10s) 10000 Frequency in ms of server monitoring checks.
PG_SYNCHRONOUS_COMMIT PostgreSQL on off or local Bypasses per-transaction disk fsync. Reduces write latencies from ~15ms down to ~1-2ms for local dev and benchmarks.
PG_WORK_MEM PostgreSQL Default (4MB) 32MB or 64MB Prevents disk spilling for complex sorts/joins during dynamic content queries.
SQLITE_SYNCHRONOUS SQLite NORMAL NORMAL / OFF Controls SQLite synchronous write durability (NORMAL recommended for WAL mode).
SQLITE_BUSY_TIMEOUT SQLite 30000 30000 Busy lock wait timeout (30s) preventing concurrent writer lock errors under load.
SQLITE_WAL_AUTOCHECKPOINT SQLite 0 (disabled) <pages> to restore Page count for SQLite’s synchronous auto-checkpoint. Disabled by default so checkpoints leave the request path; set a positive page count to restore classic auto-checkpointing.
SVELTY_WAL_CHECKPOINT SQLite 1 (on) 0 to disable Idle-gated background WAL checkpointing. On by default; 0 opts out (pair with a positive SQLITE_WAL_AUTOCHECKPOINT).
SVELTY_WAL_MAX_BYTES SQLite 67108864 (64MB) Smaller cap WAL-size cap: the scheduler runs a PASSIVE checkpoint once the WAL exceeds this, even under sustained load.
SVELTY_SQLITE_GROUP_COMMIT SQLite 1 (on) 0 to disable Coalesces queued single-statement writes into one BEGIN IMMEDIATE … COMMIT (per-op savepoints keep results isolated). Measured +58 % create / +77 % update at 8c (paired A/B, 2026-10-09).
SVELTY_WRITE_COALESCE_UPDATE PostgreSQL 0 (off) 1 to enable Opt-in multi-row UPDATE … FROM (VALUES …) coalescing. Profile-dependent: +11–15 % on the 100k-doc harness, βˆ’41–54 % on the 10k-doc matrix replica (2026-10-09) β€” do not enable at the matrix profile.
SVELTY_COALESCE_UPDATE_WINDOW_MS PostgreSQL 0 (tick) 1 for opt-in runs Burst-hold window for update coalescing in ms; 1 grows batches to 8–16 rows (required for the harness-profile gain).
MARIADB_SESSION_INIT MariaDB None Custom SQL Allows executing session-level SQL commands per new connection checked out from the pool.
TEST_MODE MongoDB w: 1, j: true β€” Sandbox isolation only (bench DB + test config); the adapter never changes write concern from environment flags.
Tip

Developer Recommendation: For maximum developer velocity during local testing, migration seeding, or benchmark runs, set PG_SYNCHRONOUS_COMMIT=off in your .env file or export it in your shell environment.

πŸ”Œ Connection Pool Architecture & Keep-Warm Defaults (All 4 Adapters)

In real production workloads, traffic is rarely uniform β€” bursts and quiet periods alternate. In older versions or unoptimized setups, connection pools were configured with aggressive idle timeouts (e.g. 30s in PostgreSQL, 60s in MariaDB). Under fluctuating traffic, this caused recurring connection storms:

  1. After 30–60 seconds of low traffic, connections were terminated by the client pool.
  2. The next burst of incoming requests forced the adapter to establish dozens of fresh TCP handshakes, TLS negotiations, and authentication handshakes concurrently against the database server.
  3. This resulted in observable tail-latency spikes (500ms–2000ms p99) and unnecessary CPU load on both the CMS node and the database server.

SveltyCMS 2026-09 Hardening:

  • PostgreSQL (postgres driver): idle_timeout: 0 by default. Sockets remain connected and warm. Configurable via DATABASE_IDLE_TIMEOUT (in seconds).
  • MariaDB (mysql2/promise pool): idleTimeout: 0 by default. Configurable via MARIADB_IDLE_TIMEOUT (in milliseconds).
  • MongoDB (mongodb driver): maxIdleTimeMS: 0 by default. Sockets remain alive and ready. Configurable via MONGODB_IDLE_TIMEOUT_MS.
  • SQLite (bun:sqlite / better-sqlite3): Single-file direct memory engine; zero connection pool overhead, no TCP sockets, zero connection storm risk.
Note

For serverless or short-lived container environments (e.g. AWS Lambda) where holding open idle connections could exhaust external database connection limits, operators can set DATABASE_IDLE_TIMEOUT=30 or MARIADB_IDLE_TIMEOUT=30000 via environment variables.

πŸš€ Core CMS Application Code Optimizations (Universal Across All 4 Adapters)

In addition to database-level session settings, SveltyCMS incorporates pure application-layer code optimizations in the core API dispatcher, schema validation engine, and audit pipeline. These zero-allocation optimizations apply universally to all 4 database adapters (PostgreSQL, MariaDB, SQLite, MongoDB) as well as core CMS database tables:

  1. Copy-On-Write Request Body Sanitization (src/content/content-utils.ts):

    • prepareCollectionFields() uses a single-pass Copy-On-Write pattern (sanitize + maxLength + null-row strip in one schema walk).
    • For 99% of normal write payloads, zero shallow object copies ({ ...data }) are allocated, eliminating memory churn before reaching any database adapter.
  2. Asynchronous Non-Blocking Microtask Audit Logger (src/services/security/audit-service.ts):

    • Uses static node:crypto imports during SSR module load and a serialized chainLock queue to guarantee cryptographic hash chain integrity under concurrent writes.
    • Database after-insert audit hooks operate via non-blocking microtasks (this.log(...).catch(...)).
    • Enables tamper-evident SHA-256 hash chaining in memory without delaying HTTP response latency.
  3. High-Efficiency Role & Permission Cache (src/databases/auth/permissions.ts):

    • Evaluates RBAC permissions using compiled Uint32Array role bitsets and 5-minute LRU permission caching.
    • Reuses mapped roleIds across cache reads and writes to eliminate duplicate array mapping allocations.

πŸ›‘οΈ Benchmark Integrity & Anti-Cheating Guarantees

SveltyCMS achieves sub-millisecond query execution (0.839ms findById) through architectural efficiency, not by cheating, bypassing validation, or short-circuiting production contracts:

Integrity Standard SveltyCMS Guarantee Why It Matters
Full Security Contract Parity 100% Active (Authentication, RBAC, Valibot validation, multi-tenant isolation, SHA-256 audit logging)* SveltyCMS runs the full production middleware stack on every request. (Note: Audit logging is physically bypassed only when explicitly configured with DISABLE_AUDIT_LOGS=true during bulk benchmark data generation)
Real Database I/O 10,000 real documents on native PostgreSQL 18 / SQLite / MariaDB / MongoDB No mock responses or dummy fallbacks. Queries hit real indexed database tables. (Earlier 2026-08 tables were measured on PostgreSQL 16; the matrix now pins postgres:18.)
Zero ORM Overhead (Direct SQL Helpers) Uses native Drizzle SQL helpers & raw driver bindings rather than heavy ORM query builder abstractions Heavy ORMs (Drizzle ORM dynamic joins, Knex AST generators) consume high CPU per request. SveltyCMS bypasses ORM compilation tax to run raw parameterized SQL directly.
Verified SDK Parity 0.00% overhead verified between LocalCMS SDK and raw DB driver Accessing content via SDK matches raw database driver execution speed without proxy tax.

MongoDB

Setting Default (Docker) Recommended Why
wiredTigerCacheSizeGB 50% of RAM βˆ’ 1 GB 60-70% of available RAM On machines with <8GB RAM, the default may be too small. On machines with >16GB RAM, defaults are already generous and tuning yields minimal gain.
maxConnections 1000 2000+ Default is generous; only raise if you see connection saturation. Connections available on a typical system exceed 400k.
syncdelay 60s Keep default Lower values do NOT improve performance β€” more frequent journal flushes add I/O overhead. Verified: syncdelay=10 made 1000-doc inserts 26% slower (43ms vs 34ms) on the same hardware. Keep default at 60s; reduce only if you need tighter durability guarantees.

Measured baseline (Intel i7-13700H, 31GB RAM, Docker, WiredTiger cache 16GB):

Metric Default Notes
INSERT 1000 docs (1KB each) 34ms (29,412 ops/s) Cache already 16GB β€” no disk bottleneck
READ 1000 docs 14ms (71,429 ops/s) All in cache
HTTP E2E (Truth Audit) 1.175ms avg / 2.157ms p95 851 req/s
Note

On this machine (31GB RAM), MongoDB’s wiredTigerCacheSizeGB default is already ~16GB β€” far larger than the benchmark dataset. The tuning benefit of raising it further is minimal unless you have a dataset exceeding the cache size. On constrained machines (<4GB RAM), explicitly setting wiredTigerCacheSizeGB=1-2GB prevents OOM issues.

MongoDB tuning requires a custom config file mounted into the container (not just command flags). See MongoDB docs for details.

All 4 Adapters Now Run Under Bun

All four database adapters (MariaDB, PostgreSQL, SQLite, MongoDB) run natively under bun test β€” no need for vitest/Node.js for any database.

The fix: A v8 shim in src/utils/v8-shim.ts patches process.getBuiltinModule('v8') to return a safe stub for startupSnapshot.isBuildingSnapshot() β€” the only Bun-incompatible API call in the dependency chain:

Layer Technology Bun compat
ORM Mongoose 9.x βœ… Works
Driver mongodb npm package βœ… Works
Serialization bson βœ… Shimmed β€” v8.isBuildingSnapshot() β†’ returns false

Previously (pre-shim):

NotImplementedError: node:v8 isBuildingSnapshot is not yet implemented in Bun.
    at node_modules/bson/lib/bson.cjs:2610:30

Now: The bson package loads cleanly under Bun, and all MongoDB benchmarks pass (database-performance, transaction-acid, truth-latency, etc.) with no runtime differences from SQL adapters.

The benchmark matrix runner no longer needs special vitest dispatch for MongoDB β€” every database uses bun test uniformly.

SQLite

SQLite is embedded (no Docker tuning needed). For WAL mode (enabled by default in SveltyCMS):

Setting Default Recommended Why
PRAGMA cache_size -2000 (2 MB) -64000 (64 MB) Larger page cache reduces disk reads.
PRAGMA mmap_size 0 (disabled) 26843545600 (26 GB) Memory-maps the database file for zero-copy reads.

These SQLite PRAGMAs are set automatically by SveltyCMS on connection β€” no manual action required.

High Performance Core vs Enterprise Compliance Mode (AUDIT_CHAIN_SYNC)

SveltyCMS supports two operational modes for audit logging and revision management:

  1. High Performance Core (Default: AUDIT_CHAIN_SYNC=false):

    • Database writes (INSERT / UPDATE) return sub-5ms latencies directly to the caller.
    • Crypto SHA-256 Merkle-tree hash chaining, content revision snapshotting, and L2 cache invalidation pattern purges are dispatched asynchronously via microtask background queues.
    • Eliminates all write-path CPU & IO penalties, outperforming Payload, Directus, and Strapi on write benchmarks.
  2. Enterprise Compliance Mode (AUDIT_CHAIN_SYNC=true):

    • Cryptographic SHA-256 audit chaining and revision creation run synchronously inside the database write transaction.
    • Ensures zero-latency audit durability and instant Merkle-tree verification for ISO 27001, SOC2, and EU GDPR regulated environments.
Metric High Performance Core (false) Enterprise Compliance (true) Impact
Create Latency ~4.20 ms 17.66 ms 4.2x faster writes
Update Latency ~4.80 ms 23.82 ms 5.0x faster updates
Mixed RPS 2,800+ req/s 1,987 req/s +41% throughput

Redis

Redis is used for caching layers (L2 cache, rate limiting, session store). In-memory β€” tuning focuses on eviction policy, persistence, and connection handling.

Setting Default (Docker) Recommended Why
maxmemory 0 (no limit) 70-80% of available RAM Prevents Redis from consuming all host memory under cache-heavy workloads.
maxmemory-policy noeviction allkeys-lru Evicts least-recently-used keys when memory limit is hit. Prevents write errors on cache saturation.
tcp-keepalive 300s 60s Detects dead connections 5x faster, freeing connection slots under concurrency.
timeout 0 (no timeout) 300s Closes idle client connections after 5 minutes. Prevents connection leak accumulation.
maxclients 10000 20000 Default is generous; only raise if you see connection saturation.
save 3600 1 300 100 60 10000 (RDB snapshots) "" (disable) Disabling RDB persistence avoids fork pauses. Caution: trade durability for latency stability β€” acceptable when Redis is a cache (not primary store).
appendonly no no Keep AOF off for cache-only Redis. Enabling adds fsync overhead with no benefit for ephemeral cache data.

Measured gains with all tweaks (Intel i7-13700H, Docker, redis-benchmark -c 10 -n 5000 -d 100):

Metric Default Tuned Improvement
SET throughput 138,889 req/s 156,250 req/s +12.5%
GET throughput 161,290 req/s 166,667 req/s +3.3%
p50 latency 0.039ms 0.039ms ~same
Important

The raw throughput gain is modest because redis-benchmark uses short-lived connections. The real value of tuning is operational:

  • allkeys-lru prevents OOM write errors when cache fills (infinite improvement vs crashing)
  • tcp-keepalive=60 detects dead connections 5x faster, preventing socket exhaustion under concurrent load
  • timeout=300 auto-reclaims idle connections that would otherwise accumulate over days of uptime
  • Disabling RDB (save "") avoids fork pauses that can spike p99 latency by 100ms+

Docker Compose override example (docker-compose.override.yml):

services:
  redis:
    command:
      - --maxmemory 2gb
      - --maxmemory-policy allkeys-lru
      - --tcp-keepalive 60
      - --timeout 300

Docker Compose Override β€” All Databases

Combine all overrides into a single docker-compose.override.yml alongside your existing compose.yaml:

services:
  mariadb:
    command:
      - --max_connections=500
      - --innodb_buffer_pool_size=4G
      - --innodb_log_file_size=512M
      - --innodb_io_capacity=2000
      - --innodb_flush_log_at_trx_commit=2

  postgres:
    command:
      - -c max_connections=400
      - -c shared_buffers=2GB
      - -c effective_cache_size=6GB
      - -c work_mem=32MB
      - -c wal_buffers=64MB
      - -c random_page_cost=1.1
      - -c synchronous_commit=off

  redis:
    command:
      - --maxmemory 2gb
      - --maxmemory-policy allkeys-lru
      - --tcp-keepalive 60
      - --timeout 300
Note

MongoDB tuning requires a custom config file mounted into the container (not just command flags). See the MongoDB section above for details and the official docs for syntax.

Runtime Engine Tuning: Node.js 24 vs Bun 1.3+

SveltyCMS supports dual-target execution on both Node.js (>=24) and Bun (>=1.3):

                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚                 SveltyCMS Dual Engine                  β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β–Ό                                                 β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚   Node.js 24 Runtime    β”‚                       β”‚     Bun 1.3+ Runtime    β”‚
        β”‚       (index.cjs)       β”‚                       β”‚  (index.cjs, on Bun)    β”‚
        β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€                       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
        β”‚ β€’ Google V8 SIMD JSON   β”‚                       β”‚ β€’ Native Bun.serve HTTP β”‚
        β”‚ β€’ Mature Streams        β”‚                       β”‚ β€’ Native bun:sqlite     β”‚
        β”‚ β€’ Best for high JSON APIβ”‚                       β”‚ β€’ Zero-copy WebSockets  β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Microbenchmark Comparison (Intel i7-13700H)

Operation / Primitive Node.js 24 (V8) Bun 1.3+ (JSC) Winner Notes
Bun.serve (Native HTTP) β€” 14,290 req/s πŸ₯‡ Bun (+58%) Zero-copy C++/Zig networking
node:http Server 9,022 req/s 12,048 req/s πŸ₯‡ Bun (+33%) Emulated HTTP server
AsyncLocalStorage Baseline 2.5Γ— faster πŸ₯‡ Bun Lightweight context propagation
HMAC-SHA256 (Crypto) Baseline 2.6Γ— faster πŸ₯‡ Bun Native BoringSSL/Zig bindings
JSON.parse / JSON.stringify 1.9Γ— faster Baseline (JSC) πŸ₯‡ Node.js V8 SIMD optimized JSON parser
bun:sqlite (In-Memory) 129k RPS 274k+ RPS πŸ₯‡ Bun (+112%) Direct C FFI with zero Node binding tax

Choosing the Right Production Runtime

  • Deploy on Node.js 24 (npm run start:node / node index.cjs) when:

    • You operate high-volume REST/GraphQL APIs with heavy JSON serialization and relational databases (PostgreSQL, MariaDB, MongoDB).
    • You deploy to traditional hosts (Plesk Passenger, Docker Node containers, AWS ECS).
  • Deploy on Bun 1.3+ (bun run start:bun for the native Bun.serve adapter, or bun index.cjs to run the shared node:http entry β€” the same file Node runs, including the opt-in fast lanes) when:

    • You use SQLite / Edge deployments (bun:sqlite delivers 274k+ QPS).
    • You utilize high-concurrency WebSockets / Yjs collaboration on /ws.
    • You need fast cold starts (<100ms) on serverless or container autoscale.

Hardware-adaptive profile (2026-08, extended 2026-10)

SveltyCMS detects what the machine actually allows once at process start and tunes every resource-sensitive subsystem to it (@utils/hardware-profile β†’ shared global registry β†’ all modules/workers read the same object). Two independent axes: CPU (cores, tier) and memory (memTier). No config file required.

Effective memory β€” not host RAM. effectiveMemoryBytes resolves as HARDWARE_MEM_LIMIT_MB β†’ process.constrainedMemory() (the cgroup limit: the number that actually gets the process OOM-killed) β†’ os.totalmem(). Measured wrinkle across runtimes: Node reports 0 when unconstrained, Bun reports the host total β€” so a limit at or above the host total is treated as unconstrained and a bare-metal deployment keeps V8’s own behaviour rather than a container-derived cap.

Tier Effective limit Example
nano < 0.5 GB 256 MB VPS
small < 1 GB 512 MB container
medium < 2 GB 1 GB container
large < 8 GB 2–4 GB VPS
xlarge β‰₯ 8 GB workstation / dedicated

Effective CPU β€” cgroup quota aware. cores comes from os.availableParallelism() (libuv honours both the cgroup CPU quota and cpuset), not os.cpus().length, which reports the host’s cores inside a container. Measured in Docker on a 24-core host: --cpus=1 β†’ cores=1; --cpus=4 --cpuset-cpus=0-1 β†’ cores=2. HARDWARE_CPU_CORES overrides. Without this, a 1-vCPU container derived 24 libuv threads and disabled weak-box compression.

Derived memory budgets. Linear values are ratios of the effective limit (one constant each, so index.cjs can mirror them without a second table); tiered values come from a six-row budget table (256 MB / 512 MB / 1 GB / 2 GB / 4 GB / β‰₯16 GB):

Budget Rule 256 MB 512 MB 1 GB 2 GB β‰₯16 GB Env override
V8 heap cap 0.5 Γ— limit, clamped 128–4096 MB 128 256 512 1024 4096 NODE_HEAP_MB
RSS target (governor) 0.75 Γ— limit when constrained, else derived from heap 192 384 768 1536 β€” SVELTY_MEMORY_RSS_MB, SVELTY_MEMORY_CONSTRAINED_RATIO
Request body limit / 32, clamped 8–128 MB 8 16 32 64 128 BODY_SIZE_LIMIT_MB, API_MAX_BODY_SIZE_MB
L1 list / point cache tier table 16 / 8 32/16 64/32 96/48 256/128 SVELTY_L1_MAX_MB, SVELTY_L1_POINT_MAX_MB
DB pool / workers / compile / jobs tier caps on the CPU-derived values 4Β·2Β·4Β·5 6Β·2Β·4Β·5 8Β·2Β·4Β·5 12Β·4Β·8Β·10 32Β·8Β·32Β·50 DB_POOL_SIZE, MODULE_WORKER_POOL_SIZE, COMPILE_CONCURRENCY, JOB_CONCURRENCY

One source of truth for the pool. PostgreSQL, MariaDB and MongoDB all read dbPoolSize from the profile. The adapter-local floor (20 on small hosts, 32 on medium+) was removed (2026-10): it countermanded the profile on exactly the boxes that need the smallest pool β€” a 1-core/256 MB box derived 4 connections and got 20. DATABASE_MAX_CONNECTIONS / DB_POOL_SIZE remain the explicit overrides, and a dedicated high-concurrency host raises the profile with HARDWARE_CPU_BUDGET=1.

Invariant (unit-tested): heap + L1 list + L1 point ≀ 70 % of the effective limit at every tier floor, so a deployment never has to run a GC sweep just to fit β€” see tests/unit/utils/hardware-profile-memory.test.ts.

CI gate (containers, not mocks): scripts/probe/probe-hardware-tiers.ts runs the real profile inside --memory=256m --cpus=1, --memory=512m --cpus=2 and an unconstrained container and asserts the table rows above exactly (plus the ≀ 70 % invariant) β€” a 32 GB developer machine still fails the gate if the derivation stops following the cgroup. Wired as CI task 08-hardware-tiers.

Global CPU budget β€” HARDWARE_CPU_BUDGET (default 0.75). The reserved share stays for co-hosted services on all-in-one deployments; set 1 when the app server is dedicated (managed/remote DB):

Deployment Setting Effect
All-in-one VPS (DB + Redis + nginx) HARDWARE_CPU_BUDGET=0.75 (default) 25% headroom for the co-hosted stack
Dedicated app server (managed DB) HARDWARE_CPU_BUDGET=1 CMS may use the full machine

Workload-prioritized allocation β€” each subsystem gets a share of the budget appropriate to its nature (media is bursty and CPU-hungry β†’ largest slice; DB fan-out is most conservative so a co-hosted DB server never starves):

Subsystem Share of budget Env override Notes
Media (sharp/libvips) up to 50% of physical cores SHARP_CONCURRENCY 12-way parallel variant pipelines saturate first β€” measured flat beyond this
DB pool (MariaDB/PG/Mongo) 2 queries per budget core, memory-capped DB_POOL_SIZE Conservative β€” every query burns CPU on the (possibly co-hosted) DB server
Module workers 50%, capped at 8 MODULE_WORKER_POOL_SIZE Each worker loads a full module graph
Compile (content sync) 75%, capped at 32 COMPILE_CONCURRENCY CPU-bound collection compilation
Jobs (background) 50% JOB_CONCURRENCY Keeps the request path responsive
libuv threadpool available CPUs (cgroup-aware), 4–32 UV_THREADPOOL_SIZE I/O threads block β€” they don’t steal CPU from co-hosted services. Applied by the entry before the loop starts: libuv reads the variable once, so a runtime assignment is a no-op (measured)
Mongo minPool 25% of budget, capped 10 MONGO_MIN_POOL_SIZE Pre-spawned sockets scaled down on weak hosts
Compression weak boxes: gzip 4 / br 4 β€” Keeps CPU on the request path, not the ratio

The detected profile is surfaced in boot logs, the setup-wizard completion response, /api/dashboard/system-info (dashboard CPU widget), and /api/system/health?verbose=true (the hardware block: tiers, cores, effective limit, derived budgets, and heapAligned). GPU: sharp/libvips has no GPU backend β€” media parallelism is CPU-tier capped; further media gains come from variant dedup/deferral, not more threads.

V8 heap cap (index.cjs). V8 on Node 24 does read cgroups β€” measured heap_size_limit 259 MB in a 256 MB container and 560 MB in a 1 GB one (unconstrained: 4288 MB; Bun: 163 MB at 256 MB) β€” but it is not conservative on tiny boxes: 259 MB of old-space on a 256 MB container leaves nothing for code space, buffers and thread stacks. Node exposes no in-process API to change it (v8.setHeapLimits is absent, and v8.setFlagsFromString('--max-old-space-size=…') does not move heap_size_limit β€” both verified), so the entry applies the profile’s 50 % cap by re-executing with the flag.

  • Skipped when the operator already set a heap flag (NODE_OPTIONS or execArgv), when SVELTY_NO_HEAP_CAP=1, when the process is unconstrained (capping a 64 GB box at a container budget would be a regression), and under Phusion Passenger (PASSENGER_* β€” it owns the process lifecycle). A loop guard (SVELTY_HEAP_REEXEC) prevents recursion; termination signals are forwarded and the child’s exit status is propagated.
  • The same entry derives the adapter-node request-body ceiling (BODY_SIZE_LIMIT) from the effective limit, floored at 16 MB so 10 MB multipart uploads keep working and the app’s own API gate is never the tighter of the two β€” a machine-blind 100 MB default is an OOM vector on a 256 MB box.
  • It also applies UV_THREADPOOL_SIZE (available CPUs, clamped 4–32) when the operator has not set it. libuv reads that variable once, before the loop starts, so the previous runtime assignment in the boot hook never took effect β€” measured with blocking pbkdf2: n=8/n=4 wall-time ratio 1.69 when set at runtime versus 1.25 when set in the environment (default pool 4 β†’ 1.78).

Self-check. Boot logs one line β€” [Boot] Hardware profile: … cpu=… mem=… heap=… rssTarget=… l1=… body=… dbPool=… workers=…. When the runtime’s heap ceiling exceeds 75 % of the effective limit, the boot emits an error (production defaults to LOG_LEVEL=error, so a warning-level notice would be filtered exactly where it is needed) naming the fix. Measured in the built server: aligned β†’ heapLimitMb 176 Β· heapAligned true; SVELTY_NO_HEAP_CAP=1 β†’ heapLimitMb 4288 Β· heapAligned false + the boot error line.

Memory governor (runtime)

V8 does not hand committed pages back on its own: after a burst (seeding, bulk import, media upload) heapUsed can fall to a fraction of the RSS docker stats reports, and the process stays at its high-water mark until it is restarted. In a local probe on Node 24 (2026-09-21, single Windows host β€” not re-measured by the test suite) a 370 MB burst fell to 59 MB on the first full GC, a second pass freed the remaining external arenas, and 3 s of idle released nothing.

The system watchdog therefore runs a governor (src/services/system/memory-governor.ts) on its own timer. It fires when any signal is over its threshold:

Signal Threshold Why it exists
heap heapUsed / heap_size_limit > 0.7 Classic live-heap pressure
live-set (heapUsed + external + arrayBuffers) / limit > 0.7 HTTP body/buffer bursts keep heapUsed flat β€” a heap-only check never sees them
rss rss > profile.rssTargetMb (= effective limit Γ— SVELTY_MEMORY_CONSTRAINED_RATIO, default 0.75), else 1.25 Γ— V8 heap limit The number that actually gets the container OOM-killed
idle reclaim quiet loop, rss > 192 MB, and rss > max(1.6 Γ— floor, floor + 128 MB) Returns the process to its own low-water mark β€” nothing to do with a container limit

The floor is the live set plus 64 MB of non-reclaimable runtime; it rises with a genuinely larger working set and ratchets down whenever a lower idle RSS is observed. SVELTY_MEMORY_RSS_MB overrides the derived RSS target in all cases. The target itself has one derivation, shared with the boot line: the hardware profile computes effective limit Γ— SVELTY_MEMORY_CONSTRAINED_RATIO and the governor adopts it, so the profile’s own log and the governor’s behaviour cannot disagree on the same box (unit-tested in tests/unit/services/memory-governor-profile-target.test.ts). Unconstrained hosts derive no target and the governor falls back to 1.25 Γ— heap limit β€” which is also the behaviour when process.constrainedMemory() reports nothing.

A sweep runs an async major GC first (marking concurrent, off the request path), a sync pass only when the first one handed back less than 8 MB and the process is still over a threshold, and trims the response-cache L1 tiers only if the live heap is still over the ratio β€” an RSS spike alone is not a reason to throw away turbo hits. The trim awaits the cache handle, so a pressured sweep during boot waits for the module instead of silently doing nothing. Cooldown starts at 5 s and doubles up to 2 minutes while sweeps stay unproductive, so a steady working set is not GC-thrashed.

Env var Default Effect
SVELTY_MEMORY_TRIGGER_RATIO 0.7 Heap / live-set share of the V8 heap limit that triggers a sweep
SVELTY_MEMORY_RSS_MB profile-derived Absolute RSS ceiling override; unset derives limit Γ— constrained ratio
SVELTY_MEMORY_CONSTRAINED_RATIO 0.75 Share of the effective limit that counts as RSS pressure (feeds the profile)
SVELTY_MEMORY_CHECK_MS 2000 Governor tick β€” a burst tail is caught within seconds, not a minute
SVELTY_L1_MAX_MB memory tier (16–256 MB) Byte budget for the list-response L1 tier (entry counts alone do not bound it)
SVELTY_L1_POINT_MAX_MB memory tier (8–128 MB) Byte budget for the point-read L1 tier
SVELTY_FILE_EXISTS_CACHE_MAX 5000 Entry cap for the media storage existence-probe cache (LRU, per storage path)
SVELTY_FILE_EXISTS_CACHE_TTL_MS 10000 TTL for a cached existence probe (positive or negative)

Native memory is not on this list. SQLite in production is configured by the hardware profile, and its thresholds now follow the effective limit (the cgroup, not host RAM): PRAGMA cache_size=-256000 (256 MB page cache on a large host) and PRAGMA mmap_size=536870912 (512 MB window). Both are process-resident, so on a memory-capped container they β€” not the JS heap β€” are what an RSS ceiling actually has to hold; SQLITE_CACHE_SIZE / SQLITE_MMAP_SIZE are the knobs, and a 3-minute soak measured the same +53 MB RSS climb with both of them minimised, i.e. the climb in that window is not SQLite’s (see the soak evidence in achievements-2026).

Observing it: every sweep logs one evidence line β€” 🧹 [Memory] pass 1/2 (rss) rss 389β†’286MB Β· heap 100β†’57MB Β· ext+ab 242β†’172MB Β· limit 4288MB Β· busy 88% β€” which is what to correlate with docker stats after a seeding run. Start with a real --memory limit (the CMS reads it via process.constrainedMemory()) or set HARDWARE_MEM_LIMIT_MB to pin a tier; /api/system/health?verbose=true then reports heapAligned and the derived budgets, and a misaligned heap is reported in the boot log at error level. --heapsnapshot-signal=USR2 gives a heap diff when a release still looks wrong.

Module graph: what loads when

Caching a module graph is only a saving if the graph was cut first β€” the registry caches what was used, and a process cannot predict a working set that was never separated from β€œeverything”. The current split, verified in the source:

Chunk Contents When it is evaluated
Request chunk Read lane, write lane, session check, active adapter, security headers β€” statically imported Process start; this is what a cached findById touches
Widget chunks One loader per factory (widgets/scanner.ts, non-eager import.meta.glob); input/display stay lazy component loads When a collection schema names the widget β€” the SDK resolves a schema, calls widgetNamesOf(), and loads only that set. The full catalog loads only when the admin widget store opens
Background Job queue, scheduler, outbox, watchdog, automation, telemetry, behavioral learner Still static in hooks.server.ts and started at READY β€” see below

The widget side is also used predictively: CacheWarmingService reads the behavioral learner’s hot collections, peeks their (already warm) schemas, and evaluates exactly the widget factories those collections name β€” so the first edit of a hot collection does not pay the chunk import. Nothing here loads the catalog.

Why the background services are still in the request graph: an earlier attempt to await import() them from the hook left their production exports undefined under Rolldown’s cyclic-chunk initializers, so they were pinned as static imports. Moving them out for real needs a separate entry (index.cjs must not import it into the same process, or the modules are evaluated anyway) β€” scoped with its prerequisites in roadmap-2026. Expect 30–80 MB resident and roughly 5–10% on create when it lands; cached reads stay at the HTTP floor either way, because they already do.

2027 Database Engine Upgrades: The 4-Phase Architecture

SveltyCMS incorporates a 4-phase modern database engine optimization pipeline designed to maximize concurrency throughput, minimize memory allocations, and guarantee predictable tail latency:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   2027 DATABASE ENGINE UPGRADES                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Phase 1: Boot Statement Warmup β”‚ Eliminates p99 planning cold-penaltiesβ”‚
β”‚ Phase 2: Write Coalescing      β”‚ Concurrent micro-batching via gate    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  1. Phase 1: Boot Statement Warm-Up (src/databases/postgresql/statement-warmup.ts)

    • Mechanism: Executes 4 key probe shapes Γ— 6 repetitions across connection pool workers at boot using impossible UUID sentinels and LIMIT 0 reads.
    • Impact: Forces PostgreSQL’s query planner to transition prepared statements from custom plans (0.36 ms) to cached generic plans (0.11 ms) before live traffic arrives.
    • Control: Toggleable via SVELTY_PG_STATEMENT_WARMUP=0 (disabled) / 1 (enabled, default).
  2. Phase 2: Adaptive Write Coalescing (src/databases/core/write-coalescer.ts)

    • Mechanism: Intercepts mutation requests (insert, update, delete, upsert) arriving within the same event-loop microtask tick and groups them into concurrent micro-batches with per-operation error isolation.
    • Impact: Reduces WAL sync contention and connection pool queue lockups during concurrency spikes (measured 361x throughput speedup under 200 concurrent writes).
    • Control: Toggleable via SVELTY_WRITE_COALESCING=0 (disabled) / 1 (enabled, default).

Compliance Disclaimer: Based on publicly available documentation as of August 2026. [EU Directive 2006/114/EC]

performanceoptimizationcachingindexingdatabasearchitecture
Was this page helpful?