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.
SQLite β Local/Edge (Verified August 2026)
| Operation | Latency | RPS |
|---|---|---|
| FIND ONE | 0.008 ms | 100β119k |
| FIND MANY (50) | 0.081 ms | 10,397β11,507 |
| INSERT | 0.038 ms | 14,170 |
| UPDATE | 0.080 ms | 9,341β11,195 |
| DELETE | 0.049 ms | 14,010β14,422 |
| NATIVE UPSERT | 0.067 ms | 11,838β12,227 |
| Peak Throughput | β | 390,617 req/s |
| LocalCMS SDK Overhead | β | 0.00% (verified parity) |
Adapter findOne (100β119k RPS) is ~50% of the raw SQLite engine ceiling (~205k) β 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 ~111k 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).
Core database layer (all 4 engines)
Optimizations live in shared core, not one dialect:
| Shared module | Engines | Effect |
|---|---|---|
core/lookup-query.ts + SqlAdapterCore.findOne |
SQLite, PostgreSQL, MariaDB | Pure {_id} / {_id,tenantId} β findById (skips full mapQuery) |
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 +52% (10.5k β 16.1k) |
Raw multi-VALUES insertMany (all SQL) |
SQLite, PostgreSQL, MariaDB | Single atomic statement per chunk (999-param SQLite / 65k-param PG & MariaDB). SQLite BULK (100) 177 β 248 RPS (+40%); PG 87 β 233 RPS (+168%, was Drizzle .returning()); MariaDB is engine-bound (~4.6ms/100 rows) so parity with Drizzle but now raw + skipReturning for outbox/seeds. Undefined values bind as column defaults: literal DEFAULT (PG/MariaDB) or explicit fills (SQLite β no DEFAULT keyword in VALUES; fills createdAt/updatedAt/status/isDeleted/data, fixing a NULL-createdAt bug on the raw path) |
| Raw single INSERT (MariaDB) | MariaDB | Drizzle mysql2 has no .returning() and pays the AST per insert β raw no-returning pool.execute + values synthesis: INSERT 219β271 β 279β317 RPS (+15%) |
skipReturning bulk (SQLite) |
SQLite + outbox flush + seeds | Seed/system-bulk callers get prepared values WITHOUT JSON.parse/flatten (was a no-op win before): bulk profile 401 β 565 batches/s; outbox flush wired to skipReturning |
| Prepared-statement cache sizing (MariaDB) | MariaDB maxPreparedStatements: 2000 (env MARIADB_MAX_PREPARED) |
mysql2 default (100) overflowed under Drizzle content queries β re-prepare churn |
| Prepared-statement cache (SQLite) | SQLite client.prepare wrapped per connection |
Drizzle re-preps every query; cache turns per-call sqlite3_prepare (~4Β΅s) into a Map hit |
Projection (options.fields) |
SQLite, PostgreSQL, MariaDB | Prunes the JSON data blob from SELECT + skips JSON.parse/flatten when all requested fields are physical |
deleteMany single statement |
SQLite, PostgreSQL, MariaDB | One UPDATE/DELETE instead of findMany + N deletes |
quoteIdentifier dialect hook |
SQLite, PostgreSQL, MariaDB | Backtick identifiers on MariaDB (double quotes are strings without ANSI_QUOTES) |
Composite index (tenantId, status, updatedAt) |
SQLite createModel DDL + PG/Maria createModel |
Serves the canonical tenant list query (WHERE tenantId=? AND status=? ORDER BY updatedAt DESC LIMIT n) from one index β measured 194 β 68k RPS at 100k rows on SQLite (no temp B-tree); PG uses updatedAt DESC for index-order sort |
Mongo composite index {tenantId, status, updatedAt} |
MongoDB createIndexes |
Same canonical list query served from one index (previously {tenantId,status} + separate sort β temp sort) |
MongoCrudMethods + shared isIdLookupQuery |
MongoDB | Lean findOne fast path; single-tenant bare _id allowed |
Mongo insertOne |
MongoDB | Avoids Mongoose Document + full validate graph on insert |
Wire compression override (MONGO_COMPRESSORS) |
MongoDB | none disables zstd/snappy (CPU-bound on small payloads β measured INSERT 711β812, FIND ONE 881β1,032 RPS on LAN); default auto-detect for WAN bandwidth savings |
Mongo date-walk skip + 3-key mapQuery fast path |
MongoDB | Content schemas store ISO strings β processDates walk skipped; safeQuery {_id,tenantId,isDeleted} shape bypasses rebuild |
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) |
| 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) |
| 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 β part of the emit contract: callers read event._id); only the DB write is deferred to the 25ms/64-event bulk flush; buffered path is env-check + push |
measured 28Β΅s β ~5Β΅s |
| 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)
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) |
Skip field walks, hooks import, and modifyRequest 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 |
Same-tick batch window for concurrent findById |
Still coalesces N+1 without always using multi-id queries |
| 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) |
SDK tax suite: tests/benchmarks/local-cms-crud.test.ts measures cms.collections.create / update / findById vs adapter findOne (not raw db.crud alone). tests/benchmarks/local-sdk-vs-direct.test.ts reports the full pipeline (all post-write side effects executing: outbox INSERT, cache-pattern invalidation, workflow init, pubsub β create β2.0k RPS, update β2.5k RPS on SQLite) and the detached number via the documented skipSideEffects: true option (create β4.9k, update β4.4k). No environment flags are involved β benchmarks run the same code path production runs.
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 |
Sync generateContentEtag |
Dropped async hash-wasm on GET miss path |
| 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 |
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) |
SQL family gets ~5-8 fewer allocations per filtered query. MongoDB gets ~2-3 fewer (result pool + fused mapQuery). All 4 databases benefit from shared BaseAdapter optimizations.
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)
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 - 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/count-cache.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
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.
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)
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.
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.
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.
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).
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 removal: standalone
query-ir.tsremoved; translator inlined into the only 2 real consumers - 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))
Background IndexOptimizer service automatically manages database performance through schema-driven indexing, background building, and multi-database support (MongoDB Compound/Text, SQL B-Tree).
Batch Relational Upserts
setMany and bulkUpdate use true multi-row INSERT ... ON CONFLICT DO UPDATE via Drizzle ORM. During setup seeding, ~80 individual DB queries β single batch operation. Dialect-specific handling (PostgreSQL/SQLite excluded.*, MariaDB/MySQL VALUES()).
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.
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 for ~10x faster writes. |
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 |
|---|---|---|---|---|
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 | 2000 |
2000 or 5000 |
Page count for auto-flushing dirty WAL pages to main file, smoothing out p99 tail spikes. |
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.
π 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):validateFieldConstraints()andstripNullRows()use a Copy-On-Write pattern.- 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 16 / SQLite / MariaDB / MongoDB | No mock responses or dummy fallbacks. Queries hit real indexed database tables. |
| 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.
Compliance Disclaimer: Based on publicly available documentation as of August 2026. [EU Directive 2006/114/EC]