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.
Performance Philosophy
Section titled “Performance Philosophy”- One Query, One Statement: Every query — including multi-hop traversals — compiles to a single SQL statement. No N+1 queries by design.
- Precomputed Ontology: Transitive closures, subclass hierarchies, and edge implications are computed once at schema initialization, not during every query.
- Batching & Transactions: Bulk collection APIs minimize round-trips for writes;
store.batch()does the same for reads. - Zero-Cost Abstractions: Type safety and ontological reasoning add no measurable runtime overhead.
N+1 Prevention
Section titled “N+1 Prevention”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 eliminates this structurally.
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 queriesconst 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_pJOIN 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)
There is no dataloader or batching layer because there is nothing to batch — the database handles the entire join graph in a single execution.
Batch Write Patterns
Section titled “Batch Write Patterns”Single vs bulk operations
Section titled “Single vs bulk operations”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) |
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 parameter limits
Section titled “PostgreSQL parameter limits”PostgreSQL has a 65,535 bind parameter limit per statement. TypeGraph automatically chunks bulk operations to stay within this limit:
- Node inserts: ~7,200 per chunk (9 params per node)
- Edge inserts: ~5,400 per chunk (12 params per edge)
You don’t need to chunk manually — pass arrays of any size and TypeGraph handles the rest.
Transaction wrapping
Section titled “Transaction wrapping”Bulk operations are individually transactional (each chunk is atomic), but if you need the entire batch to be atomic, wrap it in a transaction:
// Atomic: all-or-nothing for the entire importawait 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 partway through would leave partial data.
Choosing the right pattern
Section titled “Choosing the right pattern”// Small batch (< 100 items): individual creates in a transaction are fineawait store.transaction(async (tx) => { for (const person of people) { await tx.nodes.Person.create(person); }});
// Medium batch (100–10,000 items): bulkCreateconst 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);Batch sizing for large multi-call imports
Section titled “Batch sizing for large multi-call imports”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.
Batch reads
Section titled “Batch reads”getByIds() on node and edge collections uses a single SELECT ... WHERE id IN (...) 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 execute them over a single connection:
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(), replacing N individual findFrom/findTo calls with a single
transactional round-trip.
Connection Management
Section titled “Connection Management”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.
PostgreSQL pooling
Section titled “PostgreSQL pooling”Always use a connection pool in production. TypeGraph issues one SQL statement per query, so pool utilization is straightforward — no long-held connections or multi-statement conversations.
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 uses one connection for the duration of that single SQL statement. 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 acquires N connections simultaneously.
store.batch() runs all queries over a single connection
within an implicit transaction, reducing N connections to 1 while guaranteeing snapshot consistency.
SQLite concurrency
Section titled “SQLite concurrency”SQLite is single-writer. For best throughput:
- Use WAL with
synchronous=NORMAL.createLocalSqliteBackendapplies both (plus a 5sbusy_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-sqlite3is synchronous
Transaction isolation
Section titled “Transaction isolation”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.
SQLite always operates at serializable isolation.
Query Optimization Features
Section titled “Query Optimization Features”Precomputed Closures
Section titled “Precomputed Closures”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.
Smart Select
Section titled “Smart Select”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 databaseconst 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.
Built-in Indexes
Section titled “Built-in Indexes”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, anddeleted_at
For application-specific indexes on JSON properties, see Indexes.
SQL Compilation
Section titled “SQL Compilation”Each builder method (.where(), .limit(), .orderBy(), etc.) returns a new immutable instance.
.execute()/.toSQL()/.compile() compile fresh SQL from the AST on every call — this applies to
standard queries, aggregate queries, and set-operation queries (union, intersect, except).
Compilation is pure, in-memory string-building with no I/O, so recompiling is cheap; the query’s
actual database round-trip dominates the cost either way.
This is deliberate, not just unoptimized: a “current” (live) read binds its temporal-validity filter to the instant it’s compiled at. Caching compiled SQL across calls would freeze that instant at whichever call first triggered compilation, silently hiding any row created afterward from every later call on the same query instance.
const activeUsers = store .query() .from("User", "u") .whereNode("u", (u) => u.status.eq("active")) .select((ctx) => ctx.u);
// Each call compiles AST → SQL fresh, so a user created between these two// calls is visible to the second one.await activeUsers.execute();await activeUsers.execute();Prepared Queries
Section titled “Prepared Queries”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. The AST itself carries no timestamp, so this validation is
safe to do once and reuse. SQL text is still compiled fresh on every .execute(bindings) call, for
the same reason as above: the compiled text is what carries the current-read instant, the bound
parameter values, and the query’s structure.
When executeRaw is available (both SQLite and PostgreSQL backends), the freshly compiled SQL text
is sent directly to the driver with the bindings substituted in.
Best for: validating a query shape once (fail fast on a malformed query) and reusing it with different parameter values — not for avoiding SQL compilation cost, which is negligible next to the database round-trip.
See Prepared Queries for usage details.
Subgraph extraction
Section titled “Subgraph extraction”For the “load entity with all relationships” pattern, store.subgraph()
is the fastest strategy. It compiles to a single recursive CTE that fans out across all specified
edge types in one round trip — no matter how many relationship kinds are involved. 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.
Best Practices
Section titled “Best Practices”Filter early
Section titled “Filter early”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.
Select specific fields
Section titled “Select specific fields”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)Use specific kinds
Section titled “Use specific kinds”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.
Use cursor pagination
Section titled “Use cursor pagination”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.
Index your filter and sort properties
Section titled “Index your filter and sort properties”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.
Profile Your Queries
Section titled “Profile Your Queries”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);Benchmarks
Section titled “Benchmarks”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/1000hop recursive withcyclePolicy: "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 |
Real-world workload validation
Section titled “Real-world workload validation”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.
Running benchmarks locally
Section titled “Running benchmarks locally”pnpm benchFor guardrail mode (fails on regression thresholds):
pnpm --filter @nicia-ai/typegraph-benchmarks perf:checkRun the same guardrailed suite against PostgreSQL:
POSTGRES_URL=postgresql://typegraph:typegraph@127.0.0.1:5432/typegraph_test \ pnpm --filter @nicia-ai/typegraph-benchmarks perf:check:postgresBy 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:
pnpm --filter @nicia-ai/typegraph-benchmarks bench:write # sqlite, in-memorypnpm --filter @nicia-ai/typegraph-benchmarks bench:write:file # sqlite, on-diskPOSTGRES_URL=... pnpm --filter @nicia-ai/typegraph-benchmarks bench:write:postgresThe 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/.
Next Steps
Section titled “Next Steps”- Indexes — Define custom indexes for your schema
- Query Profiler — Identify missing indexes automatically
- Backend Setup — Connection setup, pooling, and lifecycle