Local SDK vs HTTP/GraphQL API
Choose between the high-performance internal Local SDK and the external REST/GraphQL API in SveltyCMS.
On this page
Both access patterns are derived from a single, unified OpenAPI 3.1.0 contract, ensuring 1:1 parity and maximum flexibility for any setup:
- External HTTP / GraphQL API — The secure, public-facing interface with the full middleware stack (auth, rate limiting, CORS, etc.). Now featuring automated population and real-time subscriptions.
- Internal Local SDK — A zero-overhead, server-side-only API for maximum performance in your SvelteKit backend code.
Recommendation: In all server-side code (+page.server.ts, actions, hooks, etc.), prefer the Local SDK unless you have a specific reason to test the full HTTP stack.
When to Use Which
| Context | Recommended | Latency | Reason |
|---|---|---|---|
Client-side (.svelte, +page.ts) |
HTTP / GraphQL API | 50–150 ms | Browser environment; requires auth, CORS, and network. |
Server-side (.server.ts, actions) |
Local SDK | 2–15 µs | Direct function calls; zero serialization or network overhead. |
| Mobile / External App | GraphQL | 80–300+ ms | Optimized data fetching with nested population. |
| Plugins / Background Jobs | Local SDK | 2–15 µs | Full access to core logic and security context. |
Rule of thumb: If your code runs server-side (files ending in .server.ts or inside +server.ts routes), use the Local SDK.
Use GraphQL for external integrations that require complex nested data in a single request.
Why the Local SDK Exists
The Local SDK (src/services/sdk/index.ts — the LocalCMS class) is a high-performance facade that:
- Eliminates overhead: No network stack, JSON parsing, or serialization.
- Bypasses external security: Safely skips WAF and rate-limiting because it runs with server privileges.
- Calls Core Logic: Executes the exact same
modifyRequestpipeline as the HTTP layer. - Maintains Consistency: Automatically triggers cache invalidation and real-time events (SSE).
- Unified Context: Respects the same tenant isolation and permissions as the external API.
What is not faster than LocalCMS (2026)
gRPC / protobuf / HTTP/3 beat JSON-over-HTTP between processes. They do not beat an in-process function call. Spotify’s published backend work (gRPC as the default service-to-service protocol, proxyless xDS mesh) is for thousands of Kubernetes services talking across the network — not for a server talking to itself. In this CMS the analog is:
| Hop | What to use | Why |
|---|---|---|
| Browser → this Node process | SvelteKit remote functions (query / command) that call LocalCMS |
One typed RPC; still one HTTP to the server, then zero extra hops |
.server.ts / remotes / jobs → DB |
LocalCMS | Same process; JSON + WAF + auth stack never run |
Browser → /api/... |
REST / GraphQL | Public contract, mobile apps, webhooks |
A .remote.ts that event.fetch("/api/...") re-enters the HTTP pipeline (auth, WAF, JSON) and throws away the LocalCMS advantage. Remotes must call LocalCMS the same way +page.server.ts does (getRequestLocalCMS()). User profile, settings groups, collection save/delete remotes, and dashboard health follow that path.
HTTP/3 and QUIC still matter for clients on lossy networks; they do not replace LocalCMS on the server.
Measured Local SDK vs HTTP (SQLite, self-measured 2026-08)
Same machine, same harness (bun test tests/benchmarks/). The Local SDK is two to three orders of magnitude faster than the HTTP cold path because the HTTP middleware stack (auth, RBAC, security headers, serialization) is the dominant cost — not the database.
| Operation | Local SDK (in-process) | HTTP cold | HTTP turbo (response cache) |
|---|---|---|---|
find by _id |
64–127k RPS · 8-16 µs | 174 RPS · 5.6 ms | 408 RPS · 2.3 ms |
findById (warm L1) |
259–431k RPS · 2-3 µs | — | — |
create (full pipeline) |
2,000 RPS · 500 µs | — | — |
update (full pipeline) |
2,500 RPS · 400 µs | — | — |
create (detached) |
4,900 RPS · 200 µs | — | — |
update (detached) |
4,400 RPS · 230 µs | — | — |
Two write columns are reported deliberately and no environment flags are involved — the benchmark measures exactly what production runs. (full pipeline) = the SDK call with all post-write side effects executing (outbox event INSERT, cache-pattern invalidation, workflow init, pubsub). (detached) = the same call with the documented skipSideEffects: true option (used by bulk seed / high-throughput import paths). The ambient BENCHMARK env toggles were removed from the write path — benchmarks no longer measure a code path production never runs.
Adapter-level INSERT on the same machine is ~14k RPS (raw INSERT…RETURNING). The findMany-by-_id ultra path (routes list calls with a pure _id filter through the raw prepared findById) removed the previous ~10× list-path penalty.
Example: Local SDK Structure
The cms (LocalCMS) object provides a unified, typed interface to all system capabilities:
// Content Management
await cms.collections.find("posts", { limit: 10 });
await cms.collections.create("posts", { title: "New" });
await cms.media.list({ folderId: "uploads" });
// Identity & Access
await cms.auth.listUsers({ tenantId });
await cms.websiteTokens.create({
name: "API Key",
permissions: ["content:read"],
user,
tenantId,
});
// System & Infrastructure
await cms.system.settings.getAll();
await cms.system.getHealth();
await cms.widgets.list();
// Collection Builder organizational tree (gui-save, manifest, SSE)
await cms.contentStructure.saveGuiStructure(
[{ type: "move", node: { path: "/collection/posts", parentId: "cat-1" } }],
{ tenantId },
);
// Direct adapter access (escape hatch)
const raw = await cms.db.crud.findMany("posts", {}, { tenantId });
Collections Namespace Modules (2026-08 refactor)
src/services/sdk/namespaces/collections-namespace.ts was consolidated from a ~2,100-line
monolith into a thin orchestrator (~1,240 lines) with each concern living exactly once in its own
module under src/services/sdk/namespaces/collections/:
| Module | Responsibility |
|---|---|
schema-store.ts |
Schema LRU cache (lowercased keys), hot-path flags, model cache (WeakMap, never on schema) |
read-pipeline.ts |
Tenant+publication query building, find cache keys (FNV-1a), L1→L2 read-through cache |
write-pipeline.ts |
Single-pass field prep → hooks → numeric gate → assertWriteAllowed; widget pipeline |
post-write.ts |
Post-write side effects: outbox batch, tick-debounced invalidation (collection + API patterns only — not cms:content_structure), workflow/pub-sub/hooks |
request-cache.ts |
L1 request cache (bounded LRU) + scoped keyspace index for eviction |
lazy-services.ts |
Memoized dynamic imports (workflow, response-cache, pub-sub, outbox, token engine, …) |
Key hot-path changes:
- One-pass write field prep —
prepareCollectionFields()insrc/content/content-utils.tsreplaces the former three-walk chain (sanitizeCollectionFields→stripNullRows→validateFieldConstraints) with a single schema walk and at most one shallow clone. - Shared hook orchestration —
applySchemaHookPipeline()(with an optionalcreateErrorfactory so write paths still throwAppError(400, "FIELD_VALIDATION_ERROR")) now serves bothcreateandupdateinstead of duplicated inline pipelines. - Physical table names —
collectionTableName()insrc/databases/core/collection-name.tsis the single source forcollection_<id>derivation (namespace + API handlers). - Per-item dynamic import removed from
list()(token engine now a memoized singleton);findByIdschema-cache key casing fixed;findStreamingnow shares the schema cache. - Widget pipeline —
modifyRequestcaches active widgets on the fields array; DateTime uses a statictoISOStringimport (no per-field dynamic import). - Cross-chunk singletons —
responseCache, webhook, and automation services bind toglobalThisso Rolldown chunks share one instance.
Security invariants (unchanged)
The refactor is behavior-preserving by design. The write path still enforces, in order:
field sanitization (stored-XSS prevention) → constraint/numeric validation → schema hooks →
assertWriteAllowed for non-admin writers → persist → detached side effects. Read paths still
clamp publication visibility (resolvePublicationFilter + status bound into the DB query),
keep the per-filter cache suffix (a cached “all” document can never reach a clamped caller),
and always inject tenantId. Post-write side effects (outbox, workflow, pub-sub, plugin hooks)
remain best-effort and never surface to the caller.
Example: Data Loading in +page.server.ts
import { LocalCMS } from "@src/services/sdk";
import { getDb } from "@src/databases/db";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
const cms = new LocalCMS(getDb()!);
const posts = await cms.collections.find("posts", {
limit: 10,
tenantId: locals.tenantId,
});
return { posts };
};
Example: Bulk Operations
// Collections namespace supports bulkCreate, bulkUpdate, bulkDelete
const result = await cms.collections.bulkCreate("products", largeDataArray, {
tenantId,
});
Technical Details
Local SDK Guarantees
- Widget/request modification pipeline (
modifyRequest) runs identically to HTTP requests. - Cache hooks, invalidation, and version bumping happen automatically.
- Real-time updates (SSE / WebSocket) are triggered for connected clients.
- Multi-tenancy, user context, and permissions are applied consistently.
- Media Intelligent Storage: Shared deduplication and loop protection logic for all uploads via
cms.media.upload(). - Bulk operations:
cms.collections.bulkCreate(),bulkUpdate(),bulkDelete()for high-throughput tasks. - Direct adapter access:
cms.dbprovides escape-hatch access to the rawIDBAdapterwhen the SDK namespace doesn’t cover a needed operation.
When You Might Still Use HTTP from Server Code
- Calling a completely external microservice or third-party API.
- Deliberately exercising the full external middleware (e.g., for testing or logging).
- Cross-origin or cross-instance communication.
Best Practices
- Default to Local SDK in every
.server.tsfile, server actions, and hooks. - Avoid
fetch('/api/...')inside server code — it adds unnecessary latency and stack depth. - Instantiate via
new LocalCMS(adapter)from@src/services/sdkfor clean, typed access. The instance is lightweight and safe to create per-request. - Use
cms.dbwhen you need raw adapter access — e.g.,cms.db.system.websiteTokens.getByToken().
---
## Related
- [API Reference](/docs/development/index)
- [Getting Started](/docs/getting-started)