Tuning page cache and heap for bulk Neo4j loads
Two memory settings decide whether a bulk load finishes inside its cutover window or crawls to a halt partway through: server.memory.pagecache.size and the server.memory.heap pair. Get the page cache too small and the load degrades to random disk I/O the moment the store outgrows it — the classic “it got slow after four million rows.” Get the heap oversized and you trade one problem for another, because a large heap under a write-heavy load produces long stop-the-world garbage-collection pauses that surface as intermittent TransactionTimedOutError. This page shows how to size both for a cold bulk load: the page cache to hold the store and its indexes, the heap for transaction state and no larger, and how the two settings couple to your batch size. It is the memory-sizing companion to initial load performance tuning, which covers constraint ordering and chunking around these knobs.
Prerequisites
The two budgets do different jobs
Page cache and heap are not interchangeable pools you split by taste — they serve different subsystems and are sized by different quantities. The page cache is Neo4j’s cache of the store files: node records, relationship records, property records, and every index. A traversal or a MERGE that finds its target already resident reads from RAM; one that misses pages it from disk. During a load the working set is the store you are actively writing plus the indexes backing your MERGE keys, so the page cache must be large enough to hold that hot set or the load falls off a cliff to disk-random-read speed. The heap holds transient transaction state: the rows of the current batch, the UNWIND list, the undo log, query execution structures. It is sized by how much work is in flight at once, which is your chunk size times the per-row footprint — not by the size of the data at rest.
That distinction produces the counter-intuitive rule: a bigger heap is not better. Beyond what the in-flight transaction state needs, extra heap is dead weight the garbage collector must still scan, and on a write-heavy bulk load that means longer pauses, not more throughput. Give the heap enough for the batch and give everything else you can spare to the page cache. The diagram shows how a host’s RAM should be partitioned for a load.
A sizing calculation makes both concrete. Let $S_{store}$ be the finished store size and $S_{index}$ the size of the backing indexes; size the page cache to hold both with headroom for growth during the load:
$$\text{pagecache} \approx 1.2 \times (S_{store} + S_{index})$$
Heap is bounded by the largest in-flight batch, where $c$ is the chunk size and $m_{row}$ the per-row working-set cost, plus a fixed base for the runtime:
$$H_{max} \gtrsim c \cdot m_{row} + H_{base}$$
The coupling is direct: chunk size $c$ appears in the heap term, so raising the batch to amortize commit overhead also raises peak heap. Size the heap for the batch you actually run, then leave the rest of RAM for the page cache — the two are drawn from the same physical memory.
Core implementation
Set both explicitly in neo4j.conf rather than trusting the defaults, which are conservative and rarely match a load host. Neo4j 5.x will even print a starting point for you with neo4j-admin server memory-recommendation. Pin the heap initial and max to the same value so the JVM never pauses to resize, and size the page cache to the store-plus-index estimate.
# Ask Neo4j for a starting point based on the host's RAM and store size.
neo4j-admin server memory-recommendation --memory=32g --docker=false
# Then set the results explicitly in neo4j.conf (static — requires a restart):
#
# # Heap: initial == max so the JVM never resizes mid-load and pauses.
# # Sized for in-flight transaction state (chunk_size x per-row), NOT the data.
# server.memory.heap.initial_size=8g
# server.memory.heap.max_size=8g
#
# # Page cache: hold the finished store files + indexes, with ~20% headroom,
# # so every MERGE seek and write hits resident pages instead of disk.
# server.memory.pagecache.size=20g
#
# Leave the remainder (here ~4 GB of 32 GB) for the OS and filesystem cache.
Two rules keep this from backfiring. First, heap initial equals heap max: a growing heap forces the JVM to resize under load, and each resize is a pause; pinning them removes that variable. Second, do not let page cache plus heap plus the OS reserve exceed physical RAM — if the JVM heap and the page cache together push the machine into swap, both slow to disk speed at once and the load stalls harder than either shortfall alone. When either the store is bigger than the machine can cache or the load exceeds ~100M nodes, the online Bolt path is the wrong tool; switch to the offline importer described in data loading tools and bulk ingestion, which streams to the store without a transaction log or page-cache-resident working set.
Validation & verification
Confirm the settings the running server actually loaded — neo4j.conf edits do nothing until a restart, and a typo is silently ignored. SHOW SETTINGS reads them back from the live process:
SHOW SETTINGS YIELD name, value
WHERE name IN ['server.memory.pagecache.size',
'server.memory.heap.initial_size',
'server.memory.heap.max_size']
RETURN name, value;
Then verify the page cache is actually holding the working set by watching its hit ratio during the load. A healthy load keeps the ratio high (near 1.0); a ratio that falls as row count climbs is the signature of a page cache too small for the growing store — pages are being evicted and re-read from disk:
CALL dbms.queryJmx('org.neo4j:*') YIELD name, attributes
WHERE name CONTAINS 'Page cache'
RETURN name, attributes.Faults, attributes.Hits;
Finally, size the page cache against reality by measuring the finished store on disk — neo4j-admin and the filesystem both report it — and compare it to server.memory.pagecache.size. If the store plus indexes exceed the configured cache, expect the late-load slowdown and raise the setting (or move to the offline importer) before the next run.
Edge cases & gotchas
1. An oversized heap masking a chunk-size problem. Raising heap.max_size to stop TransactionTimedOutError treats the symptom, not the cause: the timeout is usually an oversized batch holding locks too long. Lower the chunk size first — the fix the initial load performance tuning guide reaches for — and keep the heap sized for the smaller batch.
2. Page cache and heap summing past physical RAM. Setting a 24 GB page cache and a 12 GB heap on a 32 GB host pushes the JVM and the cache into swap the moment both fill. Budget explicitly: page cache + heap + OS reserve must fit in RAM with headroom, or both subsystems degrade to disk together.
3. Forgetting the restart. These are static settings. Editing neo4j.conf and expecting the running load to pick them up is a no-op — the change lands only on the next server start, which is why the SHOW SETTINGS read-back above is the real source of truth, not the file on disk.
This task sits under initial load performance tuning; the chunk-size and index-lifecycle knobs it covers are tuned in the same pass as the memory settings here.
Related
- Up: Initial Load Performance Tuning — the chunking and index-lifecycle context these memory settings sit inside.
- Data Loading Tools & Bulk Ingestion — choosing the online Bolt path versus the offline importer these rules assume.
- Error Handling & Rollback Mechanisms — recovering when a GC pause or timeout aborts a chunk mid-load.