Performance Tuning
This guide covers common performance optimizations for ArcadeDB in production.
Memory Configuration
ArcadeDB uses both JVM heap and off-heap memory. Key settings:
# JVM heap (set via JAVA_OPTS or bin/server.sh)
JAVA_OPTS="-Xms2g -Xmx4g"
# Page cache size in MB (default: 25% of the maximum heap, so 4096 MB under -Xmx16g)
arcadedb.maxPageRAM=8192
The page cache lives inside the JVM heap. When the database is larger than the cache, pages are evicted and read again from disk, so a dedicated embedded or server process with a database bigger than a quarter of its heap benefits from a larger share: 40-50% of Xmx is a good starting point, leaving the rest for transactions, query working sets and indexes. A value above 80% of the heap is reduced automatically. Grow Xmx first if the heap is the limit.
JVM settings inside a fixed memory budget
When a container, a pod or a VM caps the memory of the process, the heap is only a part of what counts against the cap: the JVM also needs metaspace, thread stacks, direct buffers, the code cache and the collector’s own structures, and the OS needs file cache for the database files. Which share of the cap goes to the heap depends on who owns the process:
| Deployment | Intended setting | Why |
|---|---|---|
Server in the Docker image or in Kubernetes |
The image’s defaults: |
The cap covers heap and non-heap memory, so 75% leaves a quarter of it for the rest. ZGC keeps pauses short on a large heap, and an unpinned heap grows with the working set instead of being filled before the first collection. |
Embedded, in your own JVM |
There is no default: ArcadeDB does not start the JVM. Start with a heap of 50% of the budget when the application or the OS file cache needs room, up to 75% for a process that only runs ArcadeDB, and leave the collector at the JVM default (G1). |
The application’s own memory, and the OS file cache that serves the database files, share the budget with the heap. |
Pinning -Xms to -Xmx fills the heap with garbage before the first young collection on G1, so the process memory
climbs towards the heap size whatever the live working set is. A "peak memory" measured under a pinned heap reports the setting,
not what ArcadeDB needs. To compare memory across engines, either leave -Xms small and fix only -Xmx, or report the live
heap (the Heap used gauge in Studio, or jcmd <pid> GC.heap_info after a full collection) beside the peak.
|
The collector is chosen by ARCADEDB_OPTS_GC, not by JAVA_OPTS or JAVA_TOOL_OPTIONS: the image sets
ARCADEDB_OPTS_GC="-XX:+UseZGC -XX:+ZGenerational", and a second collector flag on the same command line makes the JVM refuse
to start ("Multiple garbage collectors selected"). To run G1 in the image, replace the variable (ARCADEDB_OPTS_GC="-XX:+UseG1GC"),
or set it to an empty value to take the JVM default.
Index Selection
ArcadeDB supports multiple index types. Choose based on your access pattern:
| Index Type | Best For | Trade-off |
|---|---|---|
LSM-Tree (default) |
Range queries, |
Slower point lookups than Hash |
Hash |
Keys only read, updated and deleted by equality ( |
No range queries and no ordering |
LSM_VECTOR |
Vector similarity search |
Specialized for ANN queries |
FULL_TEXT |
Text search ( |
Specialized for keyword search |
GEOSPATIAL |
Spatial queries ( |
Specialized for geometry |
Create appropriate indexes for your query patterns:
-- LSM-Tree for range queries (default)
CREATE INDEX ON User (email) UNIQUE
-- Hash for exact lookups
CREATE INDEX ON Session (token) UNIQUE_HASH
-- Composite index for multi-column queries
CREATE INDEX ON Order (customerId, orderDate) NOTUNIQUE
Query Optimization
Use EXPLAIN and PROFILE to understand query execution:
-- Show the query plan without executing
EXPLAIN SELECT FROM User WHERE email = '[email protected]'
-- Execute and show actual timings
PROFILE SELECT FROM User WHERE email = '[email protected]'
PROFILE really executes the statement, so it counts as a read only when the statement it wraps is a read. PROFILE INSERT … is refused on the read-only query endpoint (GET/POST /api/v1/query) and needs the command endpoint and write permission, like the plain INSERT. EXPLAIN never executes, so it is always allowed there.
|
Common optimizations:
-
Add indexes for
WHEREclause predicates — checkEXPLAINoutput for full scans -
Limit result sets — Use
LIMITto avoid fetching unnecessary records -
Project only needed fields —
SELECT name, email FROM Useris faster thanSELECT * FROM User -
Use parameterized queries — Avoid SQL parsing overhead for repeated queries
|
Parameters are worth more than they look, especially in Cypher. A parsed query is cached by its exact text, so writing the value inside the query produces a new text on every call and the cache is never reused:
For a small indexed lookup the parse dominates the cost of the whole query, so this single change is often the difference between the two forms being several times apart in latency. |
Bucket Configuration
ArcadeDB distributes records across buckets for parallel access. By default a new type gets one bucket (the arcadedb.typeDefaultBuckets setting is 1); set a higher value, or pass the number of buckets when creating the type, to get more.
For write-heavy types, add buckets to the type. ALTER TYPE adds and removes buckets by name (+ adds, - removes), and a bucket that does not exist is created:
ALTER TYPE HighWriteType BUCKET +HighWriteType_extra1
ALTER TYPE HighWriteType BUCKET +HighWriteType_extra2
For read-heavy types with few writes, fewer buckets reduce overhead; remove a bucket by name:
ALTER TYPE LookupTable BUCKET -LookupTable_extra1
Page Size Tuning
The default bucket page size (64KB) works well for most workloads. It applies to types created after the change, existing types keep theirs. Adjust for specific patterns:
# Larger pages for sequential scan workloads (analytics)
arcadedb.bucketDefaultPageSize=131072
# Smaller pages for random access workloads
arcadedb.bucketDefaultPageSize=32768
LSM-Tree indexes have their own page size (default 256KB), set with arcadedb.indexDefaultPageSize (new indexes only). A transaction that changes a page works on a private copy of the whole page, so write-heavy workloads of small transactions pay less with smaller index pages (for example arcadedb.indexDefaultPageSize=32768), while lookups are slightly faster with larger ones.
Connection Pool Settings
For remote applications, size the HTTP worker threads (there is no setting for a maximum number of connections) and the async queue based on concurrency:
# Threads executing HTTP requests
arcadedb.server.httpWorkerThreads=256
# Async queue size
arcadedb.asyncOperationsQueueSize=1024
Concurrent Requests (Query Admission Gate)
(since v26.11.1) Every request of a remote client passes through a queue before it runs. When the server is busy, requests wait and start in arrival order (FIFO). Without the queue they would all start at once, and under a burst of heavy queries the last ones would be refused by the query heap budget or exhaust the heap.
How it works
-
A request starts at once when fewer than
arcadedb.queryMaxConcurrentrequests are running and the running queries hold less thanarcadedb.queryAdmissionHeapWatermarkpercent of the query heap budget (arcadedb.queryMaxHeapRAM). Otherwise it waits in the queue. -
The wait happens before anything of the request runs. A query started from inside a running request, such as a function or a script, does not queue again.
-
A request that waits longer than
arcadedb.queryQueueTimeout, or findsarcadedb.queryQueueMaxSizerequests already waiting, is refused with a retryable error. Nothing of it ran, so the client can send it again as it is. -
When no admitted request is running, the next one starts whatever the heap, so memory held by work outside the queue cannot block it.
What goes through the queue
| Protocol | Gated | Refused with |
|---|---|---|
HTTP |
Every database request: query, command, batch load, vector and full-text search, time series, PromQL and Grafana. Begin, rollback and the health probe are exempt |
|
Postgres |
Every statement |
SQLSTATE |
BOLT |
Every query. A result stream keeps its slot until it is read to the end or discarded |
transient error |
Redis |
Every command but |
|
gRPC |
Queries, commands, record operations, searches and time series. Begin and rollback, the admin service and the streaming loads are exempt |
|
Gremlin Server |
Scripts and traversals (sessions are not gated) |
error response |
MCP |
The tools that read or write data |
tool error |
MongoDB |
Every command, but it never waits: one that cannot start at once is refused |
|
The embedded Java API does not go through the queue.
Sizing
-
arcadedb.queryMaxConcurrentdefaults to twice the number of cores, at least 4. Each running request holds one slot for as long as it runs, so long analytical queries next to short ones need room for both: a handful of long queries holding every slot makes the short ones wait and, past the timeout, be refused. -
Lower it to protect memory when most queries are heavy. Raise it when many requests spend their time waiting on disk or on clients rather than on CPU.
-
Keep
arcadedb.queryQueueMaxSize(default 256) belowarcadedb.server.httpWorkerThreads(default 500): each waiting HTTP request parks a worker thread. -
Keep
arcadedb.queryQueueTimeout(default 30 seconds) below your clients' own timeouts, so they receive the retryable refusal rather than a timeout of their own. -
A transaction that took explicit locks (
LOCK TYPE,LOCK BUCKET) keeps them while one of its requests waits in the queue.
# At most 16 requests at once, the next ones wait up to 10 seconds
arcadedb.queryMaxConcurrent=16
arcadedb.queryQueueTimeout=10000
# Disable the queue: every request starts at once (the behavior before v26.11.1)
# arcadedb.queryMaxConcurrent=0
Monitoring
Studio shows the queue in the Server tab, under Executor Pools, as "Query Admission Gate": the running requests against the limit, the waiting ones, and the refused ones. The same numbers are exported as metrics (pool=query_admission) and reported by the profiler (queryAdmission*).
-
Refusals that keep growing mean the server is undersized for the load, or the limit is too tight for it.
-
queryAdmissionHeapDeferralsgrowing while requests wait means the heap budget, not the number of slots, holds them back: give the server more heap, raisearcadedb.queryMaxHeapRAM, or reduce the size of the queries.
Write Optimization
-
Batch transactions — Group related writes in a single transaction to amortize commit overhead
-
Async operations — Use the async API for non-blocking writes (Java Async API)
-
Bulk loading — Use
BulkInsertvia gRPC or the Java embedded API for initial data loads -
WAL flush tuning — The
arcadedb.txWalFlushsetting controls whether each commit triggers an fsync. Inproductionmode it defaults to1(fsync without metadata). For bulk imports or when your storage has battery-backed write cache, you can set it to0for maximum throughput. See concepts/transactions.adoc#wal-flush-durability
Read Optimization
-
Page cache — Increase
arcadedb.maxPageRAMif cache hit rate is low -
Index coverage — Ensure queries hit indexes (check with
EXPLAIN) -
Projections — Select only the fields you need
-
Materialized views — Pre-compute expensive aggregations
High Availability Resync
-
Snapshot compression — A follower that falls too far behind is resynced with a full snapshot of the database, compressed by the leader on a single thread per transfer. At the Deflater default (level 6) this can cap a resync at a few MB/s on a fast private network. Since v26.11.1
arcadedb.ha.snapshotCompressionLeveldefaults to1(fastest DEFLATE), which keeps most of the size reduction of database pages at a fraction of the CPU. Use0to ship the files stored on very fast links, or-1to restore the previous level on a WAN or metered link where bytes cost more than CPU -
Concurrent followers — Two followers of the same database are served at the same time, up to
arcadedb.ha.snapshotMaxConcurrent(default 2). Raise it only if more than two followers can resync together and the leader has I/O and CPU to spare
Further Reading
-
Server Settings — Complete list of all configuration parameters
-
Monitoring — Track performance metrics with Prometheus
-
LSM-Tree Architecture — Understanding the storage engine