+1 (415) 649-9454

Sizing Neo4j Memory: Page Cache, Heap, and Transaction Limits That Hold Under Load

Almost every "Neo4j is slow" call we take turns out to be a memory problem wearing a query's clothes. The query plan looks fine, the indexes exist, and yet p95 latency wanders between 40 ms and 4 seconds depending on the hour. Nine times out of ten the graph no longer fits in the page cache, the heap is being shredded by garbage collection, or one runaway transaction is eating the memory everyone else needed.

This tutorial is the sizing and tuning procedure we run on those engagements: measure the graph, size page cache and heap from evidence, set transaction memory limits so one bad query cannot take the instance down, and verify the result under load. Everything here applies to self-managed Neo4j 2025.x/2026.x (Community and Enterprise); the Aura notes at the end cover what changes when the knobs are not yours.

The three pools you are actually tuning

Neo4j memory splits into four buckets, and mixing them up is the root of most bad configurations:

PoolSettingWhat lives there
Page cacheserver.memory.pagecache.sizeOff-heap copies of store files: nodes, relationships, properties, indexes
Heapserver.memory.heap.initial_size / max_sizeQuery execution state, transaction state, driver buffers, caches
Transaction memorydb.memory.transaction.total.max, db.memory.transaction.maxHeap sub-budget, tracked and enforced per database and per transaction
OS + everything else(whatever is left)Lucene index reads, native allocations, the OS file cache, your monitoring agent

The single most common misconfiguration: someone gives the JVM 60% of RAM as heap, leaves the page cache at its default, and wonders why every traversal hits disk. Heap is for working on data; page cache is for holding it. On a graph-shaped workload, the page cache almost always deserves more than the heap.

Step 1 — measure the store before you guess

Neo4j ships a memory recommendation tool that reads your actual store files:

# self-managed, from $NEO4J_HOME
bin/neo4j-admin server memory-recommendation --memory=64g --docker

It prints suggested heap and pagecache values plus a breakdown of store and index sizes. Treat it as a starting point, not gospel — it does not know your concurrency.

Get the ground truth yourself too:

du -sh data/databases/neo4j/*.db data/databases/neo4j/schema 2>/dev/null | sort -h | tail -20

And from Cypher, the size the database itself reports:

SHOW DATABASES YIELD name, currentStatus, store, requestedStatus;

CALL dbms.queryJmx("org.neo4j:instance=kernel#0,name=Store file sizes")
YIELD attributes
RETURN attributes.TotalStoreSize.value / 1024 / 1024 AS total_mb;

Write down three numbers: total store size, index size, and hot fraction — the share of the graph your queries actually touch. Hot fraction is the judgement call. A recommendation engine touching the last 90 days of a five-year graph might be at 20%; a fraud investigation tool that walks arbitrary history is effectively at 100%.

Step 2 — size the page cache from the store, not from RAM

The target is simple:

page cache ≈ (store size + index size) × hot fraction × 1.2

The 1.2 leaves room for growth and for the store being fragmented. If that number fits comfortably in RAM, take it and stop; buying page cache you do not need is wasted money. If it does not fit, you have a decision to make (more RAM, a cluster with read replicas, or accepting disk reads on cold data) — and it should be an explicit decision, not an accident.

Then confirm empirically. The page cache hit ratio is the metric that tells you whether you got it right:

CALL dbms.queryJmx("org.neo4j:instance=kernel#0,name=Page cache")
YIELD attributes
RETURN attributes.HitRatio.value AS hit_ratio,
       attributes.UsageRatio.value AS usage_ratio,
       attributes.Faults.value AS faults;

Or, if you have Prometheus wired up (see our observability tutorial), graph neo4j_page_cache_hit_ratio and neo4j_page_cache_usage_ratio over a full week.

Read them together:

  • hit ratio > 0.98, usage ratio < 0.8 — comfortable. You may even be able to give memory back.
  • hit ratio > 0.98, usage ratio ≈ 1.0 — full but working. Watch it; you are one data load away from thrashing.
  • hit ratio < 0.95, usage ratio = 1.0 — undersized. Every miss is a disk read inside a traversal, and traversals do a lot of reads.
  • hit ratio low right after restart — normal. Warm the cache before judging (below).

Step 3 — size the heap from concurrency, not from a percentage

Heap requirements scale with concurrent query state, not with graph size. A rough model that has held up well for us:

heap ≈ (peak concurrent transactions × average transaction working set) × 2, floor of 8 GB, ceiling of 31 GB

The 31 GB ceiling is real and worth understanding: above roughly 32 GB the JVM turns off compressed ordinary object pointers, so every reference gets bigger and you can end up with less usable heap after raising the number. If you genuinely need more than 31 GB of heap, you usually need to fix a query that materialises too much instead.

Set initial and max to the same value so the JVM never resizes under load:

# neo4j.conf
server.memory.heap.initial_size=16g
server.memory.heap.max_size=16g
server.memory.pagecache.size=40g

# Leave ~10-15% of RAM for the OS, Lucene, and native allocations.
# 64 GB box: 16 heap + 40 page cache + 8 spare.

Then watch garbage collection rather than heap size:

grep -c "Pause Full" logs/gc.log       # should be 0 in steady state

In Prometheus, neo4j_vm_gc_time_total climbing steadily, or GC pauses over ~200 ms appearing in logs/debug.log, means the heap is too small or a query is allocating far more than it should.

Step 4 — cap transactions so one query cannot take the instance down

This is the step teams skip, and it is the one that prevents 3 a.m. pages. Transaction memory is tracked and enforced:

# total heap that all transactions on this database may use together
db.memory.transaction.total.max=8g
# ceiling for any single transaction
db.memory.transaction.max=2g
# instance-wide cap across all databases
dbms.memory.transaction.total.max=12g

Keep db.memory.transaction.total.max at roughly half the heap; the rest is for everything that is not transaction state. A query that exceeds db.memory.transaction.max now fails with TransactionOutOfMemoryError — one failed request instead of an OOM-killed instance that drops every in-flight transaction on the floor.

Pair it with time limits:

db.transaction.timeout=120s
dbms.transaction.concurrent.maximum=1000

And check what is actually running when things get tight:

SHOW TRANSACTIONS
YIELD transactionId, currentQuery, elapsedTime, allocatedBytes, status
WHERE allocatedBytes > 100000000
RETURN transactionId, elapsedTime, allocatedBytes / 1024 / 1024 AS mb, currentQuery
ORDER BY allocatedBytes DESC;

TERMINATE TRANSACTION "neo4j-transaction-123" ends the offender without restarting anything.

The usual culprits behind a single enormous transaction are the same three every time: an unbatched write (fix with CALL { ... } IN CONCURRENT TRANSACTIONS, covered in our bulk writes tutorial), an eager aggregation over the whole graph, and collect() on a supernode's relationships. A PROFILE will show you which — see reading a PROFILE plan.

Step 5 — warm the cache after restart

A freshly restarted instance has an empty page cache, so the first few minutes of traffic look terrible and people conclude the tuning failed. Enterprise Edition warms the cache automatically if you let it:

db.memory.pagecache.warmup.enable=true
db.memory.pagecache.warmup.preload=true

It keeps a profile of which pages were resident and reloads them on start. On Community, or for rolling upgrades where you want the node hot before it takes traffic, do it manually and only then add the instance back to the load balancer:

MATCH (n) RETURN count(n);
MATCH ()-[r]->() RETURN count(r);

This matters most on Kubernetes, where a rolling upgrade can cycle every pod in minutes — see Neo4j on Kubernetes.

Step 6 — verify under real load

Tuning validated on an idle instance is not validated. Replay a representative hour of production traffic (query logs are the best source) and capture, before and after:

  1. p50 / p95 / p99 query latency
  2. page cache hit ratio and usage ratio
  3. GC pause count and total pause time
  4. peak allocatedBytes across transactions
  5. error counts, especially transaction-memory failures

If p95 improved but p99 got worse, you usually have one query class that is now hitting the transaction cap — that is a query to fix, not a limit to raise.

A worked example: 64 GB box, 180 GB store

A client ran a 180 GB supply-chain graph on a 64 GB instance with 32 GB heap and a default page cache. Hit ratio sat at 0.91, full GCs happened several times a day, and p95 on their main traversal was 2.1 s.

Analysis: indexes were 14 GB, and their queries only ever touched the current quarter's shipments — a hot fraction of about 25%. Target page cache: (180 + 14) × 0.25 × 1.2 ≈ 58 GB, which does not fit. But their concurrency was modest — 40 concurrent transactions, small working sets — so the 32 GB heap was pure waste.

New configuration: 12 GB heap, 44 GB page cache, db.memory.transaction.total.max=6g, db.memory.transaction.max=1g. Hit ratio rose to 0.986, full GCs stopped, p95 fell to 260 ms. No hardware change, no query rewrite — the memory was simply in the wrong pool.

What changes on Aura

On Aura you do not set heap or page cache; you choose an instance size and Neo4j sizes the pools for you. The work shifts to three things: picking a tier from the same store-size-times-hot-fraction arithmetic, keeping transactions inside the limits the tier enforces, and watching the metrics Aura exposes rather than JMX. The query-side discipline is identical — batching, avoiding eager aggregations, and not collecting supernode relationships matter more when you cannot simply add RAM. If you are weighing self-managed against Aura, our Aura and managed cloud page walks through the trade-offs.

A checklist you can run quarterly

  • Store size and index size recorded; growth trend known
  • Page cache sized from store × hot fraction, not from a RAM percentage
  • Heap ≤ 31 GB, initial = max, justified by concurrency
  • db.memory.transaction.max and .total.max set on every database
  • db.transaction.timeout set
  • Page cache warmup enabled (or a manual warm step in your deploy)
  • Hit ratio, usage ratio, and GC time alerting in Prometheus
  • Load test replayed after every config change

Memory sizing is not a one-time task: a graph that doubles in size will quietly fall out of its page cache, and the symptom will look like a slow query. Pair this with our health check checklist and run both on a schedule.

If your instance is already misbehaving and you would rather not do the archaeology yourself, our Neo4j consultants do this sizing exercise as a fixed-scope engagement — measurement, configuration, load-test verification, and a written report. Get in touch with your store size, instance size, and a week of latency graphs, and we can usually tell you where the problem is before we start.