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.
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 |
Merge laws
- 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.
- 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. - Cell 4 stays a machine-specific reference ceiling, never a target the CMS claims to exceed.
- 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 whyfindByIdmeasures ~32 % of the floor whilelistPlainreaches ~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) |
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 productionDockerfile) 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
heapTotal342.8 / 344.0 / 347.9 MB β the 2 Γ 128 MB semi-space plus old space β withheapUsedoscillating 72β176 MB and no trend (tail slope β0.10 to +0.06 MB/min, |t| β€ 0.3) and flatexternal. Anidlecontrol 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 ourheapUsedpeaks 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
Dockerfileis built onnode:24-slimwith these flags pre-configured, coupled with a fast, zero-dependencycurlhealthcheck (~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) |
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:
- Valibot sanitization (once, in core).
- Cached schema + widget transforms (
fields._activeWidgets, reused accessor). db.crud.insert/update/findon the active adapter.- Single-statement native driver execution (SQL
UPDATE β¦ RETURNING/ MongofindOneAndUpdate$set). - 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:
- 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. - Structural Information-Hiding & Anti-Introspection:
Because
WeakMapdoes 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. - Tenant & Data Isolation:
WeakMapin 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 bytenantIdboundaries 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:
- RFC 7240 Standard Header:
Prefer: return=minimal - 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.findPagewithlimit+1hasMore +total: "exact"(count goes through the 30s L1 wrapper) - SQL engines β parallel
SELECT β¦ LIMIT n+1+COUNT(*)(Promise.allinrelational-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
ICrudAdaptercontract β 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
offsetwhen cursor is set (avoidsOFFSET Nscans) - Filter merge uses Mongo-style
$lt/$gt/$or/$andβ SQLmapQueryand Mongo both understand them - Default sort when omitted:
{ _id: -1 }for stable keyset order - Tiebreak follows the sort direction: the
_idtiebreak appended to a paginatedORDER 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 / blockingSORTstage - Direction spellings are normalized per engine: the wire form (
"asc"/"desc") and the Mongo-style numeric form (1ascending /-1descending, as emitted bydefaultPageSortOption) produce the same clause everywhere βnormalizeSortDirection()incore/page-utils.tsis the single definition - Legacy plain
_idstrings 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 bycacheService) - Tags:
count,count:{collection},collection:{collection}β cleared withinvalidateQueryCache - 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Γ |
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+addSingleConditionbuildSQL[]directly - Mongo path (
mongodb/adapter-core.ts):addMongoConds+addMongoConditionbuild filter objects directly - Eliminated: QueryIR, LogicalGroup, QueryCondition[] intermediates (~3-5 objects per filtered query)
- Eliminated: All
Object.entries()array allocations (replaced withfor...in)
Structural Improvements (Shared by All Adapters)
- Proxy extraction: 3 scattered Proxy factories β 1 shared
core/proxy-utils.tswith 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) andwire-descriptor.tsremoved 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
$infilter translations with explicit, database-agnosticdb.crud.findByIdsindex 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,
createReplicaRouterAdapterreturns 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.
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=offand largershared_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. |
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:
- After 30β60 seconds of low traffic, connections were terminated by the client pool.
- 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.
- 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 (
postgresdriver):idle_timeout: 0by default. Sockets remain connected and warm. Configurable viaDATABASE_IDLE_TIMEOUT(in seconds). - MariaDB (
mysql2/promisepool):idleTimeout: 0by default. Configurable viaMARIADB_IDLE_TIMEOUT(in milliseconds). - MongoDB (
mongodbdriver):maxIdleTimeMS: 0by default. Sockets remain alive and ready. Configurable viaMONGODB_IDLE_TIMEOUT_MS. - SQLite (
bun:sqlite/better-sqlite3): Single-file direct memory engine; zero connection pool overhead, no TCP sockets, zero connection storm risk.
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:
-
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.
-
Asynchronous Non-Blocking Microtask Audit Logger (
src/services/security/audit-service.ts):- Uses static
node:cryptoimports during SSR module load and a serializedchainLockqueue to guarantee cryptographic hash chain integrity under concurrent writes. - Database
after-insertaudit hooks operate via non-blocking microtasks (this.log(...).catch(...)). - Enables tamper-evident SHA-256 hash chaining in memory without delaying HTTP response latency.
- Uses static
-
High-Efficiency Role & Permission Cache (
src/databases/auth/permissions.ts):- Evaluates RBAC permissions using compiled
Uint32Arrayrole bitsets and 5-minute LRU permission caching. - Reuses mapped
roleIdsacross cache reads and writes to eliminate duplicate array mapping allocations.
- Evaluates RBAC permissions using compiled
π‘οΈ 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 |
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:
-
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.
- Database writes (
-
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 |
The raw throughput gain is modest because redis-benchmark uses short-lived connections. The real value of tuning is operational:
allkeys-lruprevents OOM write errors when cache fills (infinite improvement vs crashing)tcp-keepalive=60detects dead connections 5x faster, preventing socket exhaustion under concurrent loadtimeout=300auto-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
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:bunfor the nativeBun.serveadapter, orbun index.cjsto run the sharednode:httpentry β the same file Node runs, including the opt-in fast lanes) when:- You use SQLite / Edge deployments (
bun:sqlitedelivers 274k+ QPS). - You utilize high-concurrency WebSockets / Yjs collaboration on
/ws. - You need fast cold starts (<100ms) on serverless or container autoscale.
- You use SQLite / Edge deployments (
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_OPTIONSorexecArgv), whenSVELTY_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 blockingpbkdf2: 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 β
ββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββ
-
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 0reads. - 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).
- Mechanism: Executes 4 key probe shapes Γ 6 repetitions across connection pool workers at boot using impossible UUID sentinels and
-
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).
- Mechanism: Intercepts mutation requests (
Compliance Disclaimer: Based on publicly available documentation as of August 2026. [EU Directive 2006/114/EC]