Skip to content

Performance Overview

TypeGraph is designed to be a high-performance, low-overhead layer on top of your relational database. By leveraging the power of modern SQL engines (SQLite and PostgreSQL) and precomputing complex relationships, TypeGraph ensures that your knowledge graph scales with your application.

  1. One Fluent Query, One Statement: Every fluent query — including multi-hop traversals — compiles to a single SQL statement, so its statement count never grows with the size of the graph. This prevents compiler-generated N+1 work inside that query; application code can still create an N+1 by issuing separate reads in a loop. (Compilation, not execution: a query whose selective-field mapping falls back re-runs as a full fetch, costing a second statement. See Batch reads.)
  2. Precomputed Ontology: Transitive closures, subclass hierarchies, and edge implications are computed once at schema initialization, not during every query.
  3. Batching & Transactions: Bulk collection APIs minimize round-trips for writes. On the read side that job belongs to the query compiler — store.batch() only caps concurrency at one query in flight, it does not reduce round trips and it is not a snapshot.
  4. Zero-Cost Abstractions: Type safety and ontological reasoning add no measurable runtime overhead.

A common performance problem in ORMs is the N+1 query: you fetch N entities, then issue one query per entity to load related data. TypeGraph’s fluent query compiler eliminates that pattern inside one graph-shaped query; it cannot eliminate separate collection reads issued by application code.

Every query — regardless of how many traversals it chains — compiles to a single SQL statement using Common Table Expressions (CTEs). Each traversal step becomes a CTE that joins against the previous one:

// This compiles to ONE SQL statement, not 3 separate queries
const results = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.name.eq("Alice"))
.traverse("worksAt", "employment")
.to("Company", "c")
.traverse("locatedIn", "location")
.to("City", "city")
.select((ctx) => ({
person: ctx.p.name,
company: ctx.c.name,
city: ctx.city.name,
}))
.execute();

The generated SQL looks like:

WITH cte_p AS (
SELECT ... FROM typegraph_nodes
WHERE graph_id = ? AND kind IN ('Person') AND ...
),
cte_employment AS (
SELECT ... FROM typegraph_edges e
JOIN typegraph_nodes n ON ...
WHERE e.graph_id = ? AND ...
),
cte_location AS (
SELECT ... FROM typegraph_edges e
JOIN typegraph_nodes n ON ...
WHERE e.graph_id = ? AND ...
)
SELECT ... FROM cte_p
JOIN cte_employment ON ...
JOIN cte_location ON ...

This holds for all query types:

  • Multi-hop traversals (N CTEs, 1 statement)
  • Recursive traversals (WITH RECURSIVE, 1 statement)
  • Aggregations with traversals (CTEs + GROUP BY, 1 statement)
  • Set operations (UNION/INTERSECT/EXCEPT of CTEs, 1 statement)

The fluent query needs no dataloader for that joined read because the database handles its entire join graph in one execution. Separate reads can still form an N+1; use a traversal, subgraph(), or the chunked collection reads described below instead of looping them or wrapping them in store.batch().

For a latency-sensitive getOrCreateByEndpoints() path, declare the canonical identity on the edge registration instead of supplying an ad hoc matchOn list at each call:

const graph = defineGraph({
id: "work",
nodes: { Person: { type: Person }, Company: { type: Company } },
edges: {
worksAt: {
type: worksAt,
from: [Person],
to: [Company],
cardinality: "many",
matchIdentity: { name: "employment", fields: ["role"] },
},
},
});
await store.edges.worksAt.getOrCreateByEndpoints(alice, acme, {
role: "engineer",
});

On a schema-managed bundled root backend (including D1 and neon-http), with claims, sidecars, history, and revision work absent, the default ifExists: "return" path combines the schema fence, endpoint validation, unique arbitration, and created/found result into one statement. A typical Neon WebSocket miss therefore falls from roughly five sequential requests to one. The found path is also one request, but the PostgreSQL implementation performs a no-op conflict update: it takes a row lock and can create write amplification, so it is not a substitute for a hot read cache.

Dynamic call-level matchOn, constrained single-edge writes, history/revision stores, caller-owned transactions, and custom backends without the matching semantic program retain the transactional path required by their additional contracts. In particular, writes made through store.transaction() remain on the interactive path and do not receive the root-path exchange-count reduction. Eligible durable bulk endpoint convergence now submits one closed native atomic exchange: the durable identity arbiter, endpoint validation, and ordered created/found results are all resolved by the program. This removes the outside probe, the transaction open/commit, and the per-item write legs for the eligible shape. The fallback bulk path still discovers exact directed endpoint pairs in set-oriented bind-budget chunks and retains its transactional contract. See getOrCreateByEndpoints for field restrictions, migration rules, and PostgreSQL retry guidance.

The one-request path is an authoritative command, not a general Store batch: the backend statement owns endpoint validation, durable-key arbitration, and the created/found result. Static adapter batches (including multi-row inserts) are separate internal optimizations and do not turn a sequence of public Store calls into one atomic operation. Use store.transaction(...) when several operations—including claims, Operational Identity, history, or revision sidecars—must commit together. Undeclared dynamic matchOn convergence keeps that interactive-transaction requirement; only a schema-declared durable matchIdentity can qualify for the one-statement root command.

Bulk endpoint convergence has a narrower native envelope than direct edge inserts. A schema-declared durable matchIdentity with cardinality: "many", the declaration’s match fields, default ifExists: "return", and no temporal mutation qualifies on an exact bundled root. The libSQL transport inventory records one client batch submission and zero client execute calls for a multi-item eligible call; this is a submission-count measurement, not a wall-clock benchmark. Dynamic matchOn, ifExists: "update", constrained cardinality, temporal options, caller transactions, derived backends, custom backends without a registered durable-convergence family, and history/revision stores intentionally retain the fallback path. Outside the native envelope, an all-live ifExists: "return" batch is the read-only exception: every backend may return that result from its single set-oriented root read without opening a confirmation transaction. Inside the native envelope, the authoritative upsert program runs first. An all-live call still returns "found" in one exchange, but the conflict-update mechanism may take incumbent-row locks and produce write amplification. Any batch outside that envelope that may create, resurrect, or update requires the complete transactional fallback. If an otherwise eligible batch resolves a tombstoned identity, the native attempt rolls back and transactionless convergence refuses with the typed CONSTRAINT_WRITE_FENCE_UNSUPPORTED (edgeMatchKeyConvergence) error. Use a transaction-capable backend when resurrection must merge partial properties through the graph’s Zod update schema.

Cloudflare D1’s 100-parameter budget admits at most seven unique durable identities in the native convergence program; duplicate inputs reuse their first identity and do not consume another program entry. Above that ceiling, an all-live return completes through the read-only one-read path described above. A batch that needs a write uses the portable fallback on a transaction-capable backend and refuses on a transactionless D1 root rather than splitting one atomic convergence contract across multiple submissions.

Bundled PostgreSQL roots using a recognized session-capable driver, Neon HTTP, Cloudflare D1, and libSQL also expose native write programs for eligible ingestion calls. Schema-managed nodes.bulkInsert(items) and nodes.bulkCreate(items) run one schema-fenced atomic program when the node has no Operational Identity, history, or revision work. The program composes each member’s complete advertised uniqueness/disjointness claim set with its fulltext/vector projection transitions. Same-kind and hierarchy-wide uniqueness, generated and caller IDs, and mixed claim families share this one program boundary. Neon HTTP, D1, and libSQL submit that program as one transport batch. Session-capable PostgreSQL runs its statements on one pinned Drizzle transaction, with one SQL statement per bind-budget chunk. The batch may use generated IDs, caller-supplied IDs, or a mixture of both; bulkCreate() restores its rows to input order. Claim work is chunked by member inside the same atomic submission, so Cloudflare D1 no longer has a batch-wide claimed-member ceiling. Its 100-parameter budget leaves 87 claim-input binds per member after the row and fence: a canonical claim costs six, each legacy hierarchy-wide uniqueness probe costs nine, and each legacy disjointness probe costs six. Custom executors should call the exported atomicNodeClaimInputCost() owner rather than reproduce this formula. A member beyond that complexity, identity-enabled and history/revision-tracked shapes, and other unsupported node work retain the existing transaction or fallback path. This per-member budget is distinct from the seven unique durable edge identities admitted by the convergence program above. A successful program needs no diagnostic reads. A refused claim first rolls the entire native batch back, then uses committed-state fence and claim reads to recover the same typed error as the portable path. Bundled backends diagnose the complete refused input with set-oriented node and uniqueness reads rather than one probe per member. Custom backends without those batch reads advance through 32-member concurrency windows until the earliest refusal can be selected in input order; when no claim explains the rollback, every input is covered before the honest terminal error. These failure-only reads are not part of the successful-write RTT count.

Both edges.bulkInsert(items) and edges.bulkCreate(items) use the same schema-fenced atomic program when the store has no history or revision capture. The program validates live endpoints, arbitrates declared durable matchIdentity, and maintains one, unique, and oneActive cardinality claims at the write boundary. It rolls back the whole call when any bind-budget chunk or constraint sidecar fails and restores bulkCreate() results to input order.

Eligible bulkDelete() calls use the same mutation-program boundary. Direct edge batches on bundled roots submit one schema-fenced atomic program; the statement refuses an ID owned by another edge collection and rolls back every chunk. Restricted node batches also submit one exchange and release uniqueness and disjointness claims owned by the tombstoned rows in that program. The node statement rechecks connected live edges at the write boundary, so a restricted delete cannot race an earlier application-side probe. Cascade, disconnect, projection, identity, captured, derived-backend, unregistered custom-backend, and caller-transaction shapes keep the interactive path.

The same exact-root programs serve eligible singleton update() and delete() calls without changing their per-operation hook contract. On an interactive PostgreSQL root, the guarded mutation owns a short transaction; on the single-submission transports it remains one batch. A node update with no unique/identity sidecars (disjointness has no update-side transition) may carry its fulltext/vector replacements in the same program. A cardinality: "many" edge update with no durable match identity, performs one authoritative preimage read, validates and merges properties in TypeGraph, then submits one guarded atomic update. Every direct edge delete, and a restricted node delete with supported claim cleanup, performs its existing live-row gate and then submits one guarded atomic delete. Missing or tombstoned deletes remain hook-free read-only no-ops. Temporal mutations, node cascade/disconnect, history/revision capture, ordinary derived backends, and unregistered custom families retain the complete portable transaction path. The guarded update converges optimistically rather than holding a transaction lock across its read and write. A one-row update gets four attempts; sustained same-row contention can still end in DatabaseOperationError, while larger resolved batches retain their two-attempt budget to bound retry cost.

These operations are registered through an exact-resource mutation execution profile. Create and delete are closed programs: validation and arbitration can be expressed by the submitted SQL itself. Node bulkReplaceById() is also a closed program: every item supplies a complete document, so eligible bundled roots submit missing-row creation, live replacement, tombstone resurrection, claim release/acquisition, and fulltext/vector transitions in one read-free atomic exchange. Live rows preserve their validity windows; resurrected rows receive a fresh stamped window. Operational Identity and history/revision capture retain the portable path.

bulkUpsertById() is deliberately different. It first reads authoritative stored properties, then merges and validates them before its write set is known. On bundled serverless roots, a distinct-ID batch with no claims, Operational Identity, durable edge match identity, temporal mutation, history, or revision capture submits its resolved mutation set, including node fulltext/vector replacements, as one atomic exchange. An eligible set on bundled session-capable PostgreSQL stays inside its exact open transaction and dispatches the same reviewed program through a separate registration bound to that pinned transaction. Its applied | unsupported result is explicit: unsupported is returned only before any program SQL runs, after which the collection enters the complete portable path. Update-only sets use one guarded set update. A set containing both fresh creates and live updates carries both legs plus a zero-write terminal postimage assertion in the same native batch; an incomplete preimage deliberately aborts the batch before any create can commit. Repeated IDs, resurrections, temporal changes, claims, edge sidecars, and unregistered transaction sessions retain the consolidated interactive path. The exact session may be collection-opened, supplied by store.transaction(), or adopted from the caller. A session program uses a savepoint so a deliberate database refusal can be rolled back and diagnosed without poisoning the caller’s surrounding PostgreSQL transaction.

Measured at the libSQL transport boundary, eligible plain node bulkInsert() and bulkCreate() calls each submit one exchange for generated, caller-supplied, and mixed ID batches. A one-chunk unconstrained edge batch remains 1 exchange, a durable-match batch drops from 6 transport submissions to 1 atomic exchange, and a cardinality-constrained batch drops from 8 transport submissions to 1 atomic exchange. Eligible one-chunk node and edge bulkDelete() calls likewise submit 1 atomic exchange instead of a transaction plus per-row probes/writes. On the portable edge path, one batched authoritative read plus one set-based soft delete replaces the former two statements per input. Eligible nodes.bulkReplaceById() calls submit 1 atomic exchange with no preimage read, including claim and projection sidecars. Eligible update-only and mixed create/update node and edge bulkUpsertById() calls whose preimages fit one bind-budget read submit 2 exchanges: one batched preimage read and one atomic mutation exchange. Mixed sets previously required separate create and update submissions after the read, so they fall from 3 exchanges to 2 (33%). D1’s 100-parameter budget admits 17 node mutations or 6 edge mutations per mutation statement. One D1 native submission accepts at most 512 node members or 187 edge members; larger sets fail closed to the portable path rather than constructing an unbounded transport request. Within that ceiling, the mutation program chunks statements inside one atomic batch, and a terminal postimage assertion for every chunk rolls the complete submission back if any guarded member moved. When the preimage read also exceeds its bind budget, it costs one read exchange per read chunk plus the single atomic mutation submission. Other backends derive their per-statement chunk size from their declared parameter budget and retain an absolute 512-member submission ceiling. The native exchange still contains the SQL statements needed for inserts and node projection sidecars; it groups fulltext and per-vector-slot transitions into set statements and submits them as one transaction so they do not each pay network latency. The exact previous count varies by driver and endpoint shape. The program also proves the exact durable contribution-marker identity and strategy signature in that submission. A newly constructed backend therefore pays no separate cold marker read before an eligible projected write. Missing, stale, failed, or unmaterialized evidence aborts the whole submission; the failure path then reads committed marker state to recover the existing typed contribution diagnostic. The marker proof is an additional SQL statement inside that atomic submission, so the optimization removes a network exchange rather than all server-side proof work. Schemas with more marker identities may require more than one proof statement within the same submission.

On session-capable PostgreSQL, the preimage read and mutation program remain in one collection-owned transaction. The program has a bounded number of statements independent of row count within its bind ceiling; it is not described as a single network exchange because wire-protocol drivers execute those statements on the pinned session. The gain is removal of the portable per-family/per-member write-plan fan-out while retaining whole-call rollback.

These are internal execution optimizations, not a public Store batch API. History/revision capture, ordinary derived or custom backends, dynamic get-or-create convergence, and other unsupported shapes retain their transaction or fallback behavior. Eligible mixed sets inside a bundled PostgreSQL transaction are the narrow session-bound exception.

For small numbers of writes, individual create() calls inside a transaction are fine. For larger volumes, use the bulk collection APIs — they use multi-row INSERTs and handle parameter chunking internally.

Method Returns results Use case
bulkCreate(items) Yes Need created nodes back
bulkInsert(items) No Maximum throughput ingestion
bulkUpsertById(items) Yes Idempotent import (create or update by ID)
bulkReplaceById(items) Yes Idempotent complete-document replacement by ID
bulkDelete(ids) No Mass soft-delete
trustedImportGraphStream(store, chunks) No Fastest initial load into a fresh dedicated database

The collection APIs remain the default: they validate data and maintain every configured constraint and sidecar. For a one-time initial load whose producer already guarantees those invariants, the distinct trustedImportGraphStream surface uses a single transaction, engine-native inserts, and deferred secondary-index builds. It intentionally rejects non-empty databases and graph features it cannot yet maintain.

PostgreSQL’s protocol can encode 65,535 bind parameters, while TypeGraph uses a portable 65,533-parameter budget across its bundled drivers. Bulk operations are automatically chunked to stay within that budget:

  • Node inserts: ~7,200 per chunk (9 params per node)
  • Edge inserts: ~4,680 per chunk (budgeted at 14 params per durable edge)

You don’t need to chunk manually — pass arrays of any size and TypeGraph handles the rest.

On a transaction-capable backend, each bulk method call is atomic across all of its bind-budget chunks. Eligible plain nodes.bulkInsert() and nodes.bulkCreate() calls, eligible plain node bulkDelete() calls, and direct edge bulkInsert() / bulkCreate() / bulkDelete() calls also provide whole-call atomicity on bundled transactionless roots through one native atomic exchange, including durable-match and cardinality-constrained edge batches. Other bulk shapes on a transactionless root either refuse when their contract requires a fence or use their documented non-atomic path. A certified atomic SQL program is available only to operations whose closed statement contract has been proven by the backend conformance runner; it does not make arbitrary Store calls atomic. store.transaction() refuses before invoking its callback on a transactionless root; it never presents sequential writes as atomic.

To commit several bulk calls as one unit on a transaction-capable backend, wrap them in a transaction:

// Atomic: all-or-nothing for the entire import
await store.transaction(async (tx) => {
await tx.nodes.Person.bulkCreate(people);
await tx.nodes.Company.bulkCreate(companies);
await tx.edges.worksAt.bulkCreate(employments);
});

Without the wrapping transaction, a failure in a later bulk call leaves earlier calls committed.

// Small batch (< 100 items): individual creates in a transaction are fine
await store.transaction(async (tx) => {
for (const person of people) {
await tx.nodes.Person.create(person);
}
});
// Medium batch (100–10,000 items): bulkCreate
const created = await store.nodes.Person.bulkCreate(people);
// Large batch (10,000+ items): bulkInsert (no result allocation)
await store.nodes.Person.bulkInsert(people);
// Idempotent import: bulkUpsertById (creates or updates by ID)
await store.nodes.Person.bulkUpsertById(itemsWithIds);
// Fresh dedicated database + already-validated producer:
await trustedImportGraphStream(store, interchangeChunks);

For a dataset too large for a single bulkInsert/bulkCreate call (e.g., streaming rows from a file in a loop), the size of each call matters, not just the total row count. Each call is its own transaction, and — per the default autoRefreshStatistics — can trigger a planner-statistics refresh on its own. In a large-scale bulk-load benchmark, batches of ~2,000 rows per call were consistently ~25-30% slower per row than batches of ~20,000+: fewer, larger calls amortize both the per-call transaction commit and the statistics refresh across more rows. Prefer batch sizes in the tens of thousands when looping over many calls for a large import, and consider autoRefreshStatistics: false plus one store.refreshStatistics() call after the loop if per-call refreshes still dominate.

getByIds() on node and edge collections uses SELECT ... WHERE id IN (...) — one statement per bind-limit chunk, so a single statement for id counts under the limit — instead of N individual queries. Results are returned in input order with undefined for missing entries.

const [alice, bob] = await store.nodes.Person.getByIds([aliceId, bobId]);

For multiple independent queries with different shapes and filters, use store.batch() to run them in sequence against one target. Note the cost: on a transactional backend it still issues at least one statement per query plus begin/commit, so N queries are N+2 round trips at best; without transactions there is no framing. It buys a connection profile that never peaks at N — not lower latency, and not a snapshot (PostgreSQL’s default read-committed isolation lets a later query see a newer commit):

const [activeUsers, recentOrders] = await store.batch(
store
.query()
.from("User", "u")
.whereNode("u", (u) => u.status.eq("active"))
.select((ctx) => ({ id: ctx.u.id, name: ctx.u.name })),
store
.query()
.from("Order", "o")
.select((ctx) => ({ id: ctx.o.id, total: ctx.o.total }))
.orderBy("o", "createdAt", "desc")
.limit(20),
);

Edge collection batchFind* methods (batchFindFrom, batchFindTo, batchFindByEndpoints) also participate in store.batch(). On a transactional backend they move N findFrom/findTo calls into one transaction — the statement count is unchanged either way. If the round trips are what hurt, replace the calls with a traversal (one statement) or store.subgraph() (a fixed 2 on SQLite, 3 on PostgreSQL, however large the result).

To read the edges of a set of endpoints, prefer bulkFindFrom / bulkFindTo (see Edge Collections). Where store.batch() runs N singleton reads over one connection, these widen the endpoint predicate itself to from_id IN (...) — one set-oriented statement per endpoint kind and bind-budget chunk, on the same index prefix seek the singleton read uses — and return the edges grouped per input:

const people = await store.nodes.Person.find({ limit: 50 });
const jobsPerPerson = await store.edges.worksAt.bulkFindFrom(people);
// jobsPerPerson[i] holds the worksAt edges of people[i]

This is the fix for the “list view with relationship counts” N+1: statement count grows with endpoint kinds and bind-budget chunks instead of with every item on the page. Pass limitPerInput to bound each endpoint’s fan-out.

If a view spans several source kinds and edge kinds, use the Store-level bulkFindEdgesFrom operation instead of calling each licensed edge collection separately. It accepts heterogeneous source groups and edge kinds, then executes one set-oriented statement per bind-budget chunk. Round trips therefore grow with input size, not with the number of licensed (source kind, edge kind) combinations:

const edgesBySource = await store.bulkFindEdgesFrom({
sources: [
{ kind: "Company", ids: companyIds },
{ kind: "Person", ids: personIds },
],
edgeKinds: ["employs", "owns", "dependsOn"],
});
// edgesBySource[i] identifies its source and contains that source's matching edges

Managed local Store and backend factories own and close their SQLite or PGlite resources. Bring-your-own adapter integrations leave the supplied connection or pool under application control. See Backend Setup for the ownership matrix and shutdown examples.

Always use a connection pool in production. An individual query holds a connection only while each statement runs. Most queries issue a single statement; a query whose selective-field mapping falls back issues a second. store.transaction() holds one connection for the whole callback, and store.batch() does the same for its implicit transaction.

import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Size based on your concurrency needs
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 2_000,
});
pool.on("error", (err) => {
console.error("Unexpected pool error", err);
});

Sizing guidance: Each concurrent query holds one connection for as long as its statement runs. A pool of 10–20 connections handles most workloads. If you’re running bulk imports in parallel, size up accordingly.

Reducing pool pressure with batch(): When loading multiple independent queries (e.g., a detail page with several relationship types), Promise.all can acquire up to N connections simultaneously — fewer if the pool is undersized or saturated, in which case it queues instead. store.batch() keeps at most one query in flight, so peak connection use is 1 — on a transactional backend that is literally one checked-out connection for the implicit transaction; elsewhere it is one at a time, and whether the adapter reuses the same client is its own business. It does not reduce the statement count, and read-committed isolation means it is not a snapshot.

SQLite is single-writer. For best throughput:

  • Use WAL with synchronous=NORMAL. createLocalSqliteBackend applies both (plus a 5s busy_timeout) automatically; on a bring-your-own connection set them yourself: sqlite.pragma("journal_mode = WAL"), sqlite.pragma("synchronous = NORMAL"). On file databases this makes single-operation writes roughly 5× faster than the driver defaults.
  • Batch writes in transactions rather than issuing many small commits. (One nuance: pure bulk appends of fresh pages can run marginally faster under the rollback journal than WAL, since WAL writes pages twice — the per-commit wins dominate everywhere else.)
  • For read-heavy workloads, SQLite performs well without pooling since better-sqlite3 is synchronous

PostgreSQL transactions accept an optional isolation level:

await store.transaction(
async (tx) => {
// Serializable isolation for strict consistency
const snapshot = await tx.nodes.Account.getById(accountId);
// ...
},
{ isolationLevel: "serializable" },
);

Available levels: read_uncommitted, read_committed (default), repeatable_read, serializable.

Schema-managed Stores fence writes against concurrent schema-version commits. That includes Stores opened by createStoreWithSchema, createAdapterStoreWithSchema, createVerifiedStore, or createVerifiedAdapterStore; an adapter Store constructed with a cached { reconciled } snapshot; and Stores returned by evolve() or rebound from one of those Stores. store.introspect().schemaVersion !== undefined is the runtime test.

PostgreSQL reacquires and validates the active-schema row lock at every managed write. The lock is normally reentrant and remains held to transaction end, but the repeated check is required because rolling back to a caller-created savepoint releases row locks acquired after that savepoint. At repeatable_read or serializable, a concurrent schema commit can raise PostgreSQL’s normal serialization failure; retry the whole transaction. Graph-merge commits already retry those failures automatically. Raw createStore / createAdapterStore instances without a reconciled snapshot, and writes issued directly through a backend, do not carry schema metadata and remain outside this guarantee. store.clear() also resets the cleared Store to that raw state.

SQLite always operates at serializable isolation.

When you define an ontology (e.g., subClassOf, implies), TypeGraph precomputes the full transitive closure at store initialization. Queries like .from("Parent", "p", { includeSubClasses: true }) use a pre-calculated list of kinds rather than recursive lookups at runtime.

TypeGraph automatically optimizes queries based on which fields your select() callback accesses. When you select specific fields, TypeGraph generates SQL that only extracts those fields using json_extract() (SQLite) or JSONB path extraction (PostgreSQL), rather than fetching the entire props blob.

// Optimized: Only fetches email and name from the database
const results = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.email.eq("alice@example.com"))
.select((ctx) => ({
email: ctx.p.email,
name: ctx.p.name,
}))
.execute();
// SQL: SELECT json_extract(props, '$.email'), json_extract(props, '$.name') ...

This optimization pairs well with covering indexes: if your index contains both the filter keys and the selected keys, the database can serve the query straight from the index instead of scanning the whole table — though on PostgreSQL specifically, this stops short of a true Index Only Scan for JSONB-extracted fields; see the covering indexes section for the concrete limitation and a workaround.

When optimization applies:

Pattern Optimized? Reason
ctx => ({ email: ctx.p.email }) Yes Simple field extraction
ctx => [ctx.p.id, ctx.p.name] Yes Multiple fields in array
ctx => ctx.p No Whole node returned
ctx => ({ upper: ctx.p.email.toUpperCase() }) Yes Field extracted; method runs in JS
ctx => ({ ...ctx.p }) No Spread requires full node

The optimization is transparent — if your callback can’t be optimized, TypeGraph automatically falls back to fetching the full node data.

For data-dependent callbacks, TypeGraph first plans with representative values, including a high-value pass that covers common numeric threshold branches. If an unobserved branch accesses an additional field at execution time, the first miss may require a second statement that fetches the full row. Prepared queries remember that missing-field failure and use the full-row plan directly on later executions. Comparisons against arbitrary string values can still take an unobserved branch; the high-value pass does not guarantee that every possible callback path is planned in advance.

The default TypeGraph schema includes optimized indexes for the most common access patterns:

  • Graph + Kind + ID: Primary key for node lookups
  • Graph + From/To ID: Optimized for edge traversals
  • Temporal columns: Indexes on valid_from, valid_to, and deleted_at

For application-specific indexes on JSON properties, see Indexes.

Each builder method (.where(), .limit(), .orderBy(), etc.) returns a new immutable instance.

A reused query instance compiles once. The first .execute() builds a cached template and every later call reuses it — for standard queries, aggregate queries, set-operation queries (union, intersect, except), and prepared queries alike. Explicit .toSQL() / .compile() calls are the exception: they compile on demand every time, because producing the statement is the thing the caller asked for.

The subtlety a cache like that has to survive is freshness. A “current” (live) read filters on temporal validity as of the instant it runs, so a template with a concrete “now” baked into it would freeze that instant for the query instance’s whole lifetime, hiding every row created afterward. The template therefore reserves the read instant as a placeholder rather than a value, and each execution fills it with a fresh instant alongside that call’s bindings. Nothing in the statement’s text depends on either, so reuse costs no freshness.

const activeUsers = store
.query()
.from("User", "u")
.whereNode("u", (u) => u.status.eq("active"))
.select((ctx) => ctx.u);
// One compilation, two executions. The read instant is bound per call, so a
// user created between these two is visible to the second one.
await activeUsers.execute();
await activeUsers.execute();

Two things fall back to compiling on every call:

  • Backends that cannot execute pre-compiled SQL text — a custom or async backend, i.e. one without executeRaw.
  • Statements whose execution semantics ride on the compiled SQL object rather than its text, even on PostgreSQL with executeRaw fully available. Two query shapes do: approximate vector search (similarTo(..., { approximate: true }), which carries the pgvector / sqlite-vec iterative-scan wrapper) and store.subgraph() on PostgreSQL, whose id-array fetches are marked to force a custom plan so the planner sizes them against the actual array rather than reusing a generic one. Flattening either to cacheable text would silently drop the behavior it depends on, so they are excluded deliberately — the trade is a template hit against correct execution, and correctness wins.

Compilation is pure, in-memory string-building with no I/O, so both fallbacks are cheap; the query’s database round-trip dominates either way. Worth knowing if you are profiling a vector query and expecting the compile-once behavior described above — that is the one shape where it does not apply.

For hot paths that execute the same query shape with different values, .prepare() builds and structurally validates the query AST once — a malformed query fails fast, before the first .execute(), instead of on first use — and compiles the statement once into a cached template. Each .execute(bindings) fills that template’s placeholders (a fresh read instant plus the call’s own parameter values) and runs the cached text directly through executeRaw.

Because arity never reaches the SQL text, a list-valued parameter reuses the same template no matter how long the list is:

const byIds = store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.id.in(param("ids")))
.select((ctx) => ctx.p)
.prepare();
await byIds.execute({ ids: ["a", "b", "c"] });
await byIds.execute({ ids: ["d"] }); // same compiled statement

Best for: validating a query shape once, then reusing it with different parameter values. The saved compilation is real but small — the database round-trip still dominates.

See Prepared Queries for usage details.

For the “load entity with all relationships” pattern, store.subgraph() is the fastest strategy. It compiles to a recursive CTE that fans out across all specified edge types in a fixed 2 statements on SQLite and 3 on PostgreSQL — no matter how many relationship kinds are involved, or how much it returns. See Choosing a query strategy for guidance on when to use subgraph() vs the fluent query builder vs manual findFrom calls.

The project option further reduces overhead by extracting only the specified fields per kind at the SQL level via json_extract() / JSONB paths, skipping full props blob transfer and metadata columns for projected kinds.

Apply .whereNode() predicates as early as possible in your query chain. TypeGraph moves these predicates into the initial CTEs, reducing the number of rows that need to be joined in subsequent steps.

When you only need certain fields, select them explicitly rather than returning whole nodes. This triggers the smart select optimization and can enable index-only scans with properly configured indexes.

// Preferred: Only fetches what you need
.select((ctx) => ({ name: ctx.p.name, email: ctx.p.email }))
// Avoid when possible: Fetches entire props blob
.select((ctx) => ctx.p)

Unless you specifically need to query across a hierarchy, avoid includeSubClasses: true. Being specific about the node kind allows the SQL engine to use more restrictive index scans.

For large datasets, prefer .paginate() over .limit() and .offset(). Keyset pagination (using cursors) avoids the O(N) cost of skipping rows in standard SQL offsets.

TypeGraph’s built-in indexes cover structural lookups (by ID, by edge endpoints). Properties you filter or sort on in whereNode(), whereEdge(), and orderBy() need application-specific expression indexes. Use the Query Profiler to identify which properties need coverage.

Use the Query Profiler to identify missing indexes and understand query patterns in your application. The profiler captures property access patterns and generates prioritized index recommendations.

import { QueryProfiler } from "@nicia-ai/typegraph/profiler";
const profiler = new QueryProfiler();
const profiledStore = profiler.attachToStore(store);
// Run your application or test suite...
const report = profiler.getReport();
console.log(report.recommendations);

TypeGraph uses a deterministic performance sanity suite as its benchmark and regression gate. The suite seeds a realistic graph shape and measures end-to-end query latency across:

  • forward and reverse traversals
  • inverse/symmetric traversal (expand: "inverse" / expand: "all")
  • 2-hop and 3-hop traversals
  • aggregate queries
  • cached execute vs prepared execute
  • deep traversals (10/100/1000 hop recursive with cyclePolicy: "allow")

Guardrail thresholds enforce expected behavior in CI (for example, traversal latency caps and ratio checks such as reverse/forward and deep-hop scaling).

Deep-recursive benchmark probes explicitly set cyclePolicy: "allow" to isolate recursive CTE expansion cost; the default cyclePolicy: "prevent" prioritizes cycle-safe semantics and is expected to be slower on long traversals.

Note: Real-world performance varies by hardware, database driver, network latency (for PostgreSQL), and schema/data shape.

Benchmark configuration and guardrails

Current suite configuration:

Setting Value
Seed users 1200
Follows per user 10
Posts per user 5
Batch size 250
Warmup iterations 2
Sample iterations (median reported) 15

Default guardrails:

Check Threshold
reverse/forward ratio <= 6x
inverse traversal latency <= 500ms
inverse/forward ratio <= 10x
3-hop latency <= 500ms
3-hop/2-hop ratio <= 8x
aggregate latency <= 500ms
aggregate distinct latency <= 700ms
aggregateDistinct/aggregate ratio <= 4x
cached execute latency <= 500ms
prepared execute latency <= 500ms
prepared/cached ratio <= 2x
10-hop recursive latency <= 250ms
100-hop recursive latency <= 1000ms
100-hop-recursive/10-hop-recursive ratio <= 30x
1000-hop recursive latency <= 5000ms
1000-hop-recursive/100-hop-recursive ratio <= 20x

Backend-specific overrides:

Backend Check Threshold
SQLite 1000-hop recursive latency <= 7000ms
PostgreSQL inverse traversal latency <= 1000ms
PostgreSQL inverse/forward ratio <= 30x
PostgreSQL 3-hop latency <= 1000ms
PostgreSQL aggregate distinct latency <= 1200ms
PostgreSQL prepared execute latency <= 700ms

Beyond the synthetic guardrail suite above, TypeGraph is also exercised against the LDBC Social Network Benchmark (SNB) Interactive workload — a standard, independently-defined graph benchmark, not a TypeGraph-specific one — at SF1 scale (~10k persons, ~1M posts, ~2M comments). This surfaced and fixed two real scaling bugs in the library: an unbounded ANALYZE cost on bulk SQLite loads, and an N+1 endpoint-existence check in batched edge creation. It also directly produced the keySystemColumns guidance and the PostgreSQL index-only-scan caveat in Indexes. The benchmark source lives in packages/benchmarks/src/real/ in the repository.

Terminal window
pnpm bench

For guardrail mode (fails on regression thresholds):

Terminal window
pnpm --filter @nicia-ai/typegraph-benchmarks perf:check

Run the same guardrailed suite against PostgreSQL:

Terminal window
POSTGRES_URL=postgresql://typegraph:typegraph@127.0.0.1:5432/typegraph_test \
pnpm --filter @nicia-ai/typegraph-benchmarks perf:check:postgres

By default the SQLite suite runs against an in-memory database, which measures engine and compile cost but not WAL/fsync behavior. Add --storage=file (or use the perf:file / perf:check:file scripts) to run against a temporary on-disk database — the lane that reflects real local deployments.

A separate write-throughput bench measures single-op creates, transaction-amortized creates, bulkCreate, search-indexed creates (fulltext + vector sync), and importGraph, normalized to milliseconds per operation:

Terminal window
pnpm --filter @nicia-ai/typegraph-benchmarks bench:write # sqlite, in-memory
pnpm --filter @nicia-ai/typegraph-benchmarks bench:write:file # sqlite, on-disk
POSTGRES_URL=... pnpm --filter @nicia-ai/typegraph-benchmarks bench:write:postgres

The write bench is report-only (no guardrails): write latency is dominated by fsync behavior on the file lane and needs per-machine calibration.

The benchmark source code is located in packages/benchmarks/src/.