+1 (415) 649-9454

Graph Model Debt: Finding Supernodes and Refactoring a Live Neo4j Graph

Most Neo4j engagements that arrive with "our queries got slow" do not have a query problem. They have a model problem that only becomes visible at production volume: a node with two million relationships hanging off it, a property that should have been a node, a relationship type that encodes a value instead of a verb. Indexes and query tuning buy you a factor of two. Fixing the model buys you a factor of fifty.

This tutorial is the modelling review we run on client graphs, turned into something you can execute yourself: how to measure your model, the four anti-patterns that cause almost all of the damage, and how to refactor a live graph in place — batched, restartable, and verifiable — using Cypher 25.

If you have not yet ruled out the cheap explanations, start with reading a PROFILE plan and the health check checklist. Come back here when the plan says Expand(All) on a node with a million relationships and no index will save you.

Step 1 — measure the model you actually have

Nobody's graph matches the whiteboard drawing from kickoff. Before changing anything, get numbers. Start with the schema Neo4j inferred:

CALL db.schema.visualization();

Then count, because visualisation hides skew:

MATCH (n)
RETURN labels(n) AS labels, count(*) AS nodes
ORDER BY nodes DESC;

MATCH ()-[r]->()
RETURN type(r) AS type, count(*) AS rels
ORDER BY rels DESC;

Two red flags to look for immediately. A relationship type whose name contains a value (ORDERED_IN_2025, HAS_STATUS_ACTIVE) means data was pushed into the schema; you cannot index it, parameterise it, or aggregate over it. A single relationship type that dominates the total count — say 80% of all edges — usually means one node type is a hub, which brings us to the real villain.

Step 2 — find your supernodes

A supernode (or dense node) is a node with a very large relationship degree. Traversals that touch it stop being graph lookups and start being full scans of that node's relationship chain. Find them:

MATCH (n)
WITH n, count { (n)--() } AS degree
WHERE degree > 50000
RETURN labels(n) AS labels,
       coalesce(n.name, n.id, elementId(n)) AS node,
       degree
ORDER BY degree DESC
LIMIT 25;

On a large graph, run that against a secondary or an analytics copy — it walks every node. count { } is the Cypher subquery counter and is far cheaper than size((n)--()) on modern versions, but it is still per-node work.

Interpretation matters more than the raw number:

DegreeVerdict
< 10,000Normal. Leave it alone.
10k–100kWatch it. Fine if you never traverse through it.
100k–1MRefactor if it sits on a hot path.
> 1MIt is the bottleneck. Refactor.

The nuance people miss: a supernode is only a problem when queries expand through it. A Country node with five million LIVES_IN relationships is harmless if you only ever ask "which country does this person live in?" (one hop, from the person side, cheap). It is fatal if you ask "which people in this country bought product X" by expanding from the country.

Step 3 — the four anti-patterns and their fixes

3.1 The lookup node

Symptom: every Person connects to one of five MaritalStatus nodes; every Order connects to one of eight Status nodes. Degree in the millions, information content of three bits.

Fix: demote it to a property with an index. A low-cardinality lookup adds a hop and buys nothing.

CREATE INDEX order_status IF NOT EXISTS FOR (o:Order) ON (o.status);

Keep the node only if it carries its own relationships (a Status that belongs to a workflow with transitions is a real entity).

3.2 Time buried in properties

Symptom: (:Account)-[:TRANSACTED]->(:Account) with 40 million relationships and a date property, and every query filters on that date. Relationship properties are not indexable the way you want them to be here, so each query walks the whole chain.

Fix: introduce a time tree, or better, an intermediate event node. Reify the relationship:

BEFORE:  (a:Account)-[:TRANSACTED {amount, ts}]->(b:Account)
AFTER:   (a:Account)-[:SENT]->(t:Transaction {amount, ts})-[:RECEIVED_BY]->(b:Account)
                                     |
                             (t)-[:ON]->(d:Day {date})

Now Transaction.ts is indexable, a range index answers "last 30 days", and the Day node lets you jump straight into a time slice instead of scanning accounts. This is also what makes graph features cheap to compute for real-time fraud scoring.

Rule of thumb: if you find yourself filtering on a relationship property in a hot query, that relationship wants to be a node.

3.3 Fan-out with no intermediate

Symptom: (:Product)<-[:VIEWED]-(:User) where popular products collect millions of VIEWED edges, and you only ever need recent or aggregated views.

Fix: two options, and they are not equivalent.

  1. Aggregate. If nobody needs individual events, store a counter or a per-day rollup node. Ten million edges become 365 nodes.
  2. Partition the relationship type. Split VIEWED into VIEWED_2026_01, VIEWED_2026_02. This does work — Neo4j stores relationship chains per type and direction, so a typed expand skips the other months entirely — but it drags a value back into the schema. Use it only when queries are always time-scoped, and generate the type name in application code, never with apoc.create.relationship on a hot path.

Prefer aggregation. Reach for partitioning when aggregation loses information the business needs.

3.4 Bidirectional duplicates

Symptom: both (a)-[:FRIEND]->(b) and (b)-[:FRIEND]->(a) exist "so traversals work in both directions."

Fix: delete half of them. Cypher can traverse a relationship in either direction at the same cost — MATCH (a)-[:FRIEND]-(b) with no arrow. You are paying double storage, double write cost, and inviting the two copies to disagree.

MATCH (a)-[r:FRIEND]->(b)
WHERE elementId(a) > elementId(b)
  AND (b)-[:FRIEND]->(a)
CALL (r) {
  DELETE r
} IN TRANSACTIONS OF 10000 ROWS;

Step 4 — refactor a live graph in place

Never run a multi-million-row refactor in a single transaction; you will exhaust the heap and roll the whole thing back at minute forty. Cypher 25 gives you the CALL { } IN TRANSACTIONS form with the modern scoped-variable syntax, which batches and commits as it goes.

Here is the full pattern for anti-pattern 3.2 — reifying TRANSACTED into a Transaction node — written so it is idempotent and restartable:

// 1. constraints first: they make step 2 fast and step 4 safe
CREATE CONSTRAINT transaction_id IF NOT EXISTS
FOR (t:Transaction) REQUIRE t.txId IS UNIQUE;

CREATE INDEX transaction_ts IF NOT EXISTS
FOR (t:Transaction) ON (t.ts);
// 2. copy: batched, resumable, marks what it has done
MATCH (a:Account)-[r:TRANSACTED]->(b:Account)
WHERE r.migrated IS NULL
CALL (a, r, b) {
  MERGE (t:Transaction {txId: r.txId})
    ON CREATE SET t.amount = r.amount, t.ts = r.ts
  MERGE (a)-[:SENT]->(t)
  MERGE (t)-[:RECEIVED_BY]->(b)
  SET r.migrated = true
} IN TRANSACTIONS OF 10000 ROWS
  ON ERROR CONTINUE;

ON ERROR CONTINUE keeps a poison row from killing the run; check for leftovers afterwards rather than at 3am. Because the driver predicate is r.migrated IS NULL, you can stop the query, restart it, and it picks up where it left off. Add an index on Account first if the match itself is slow.

// 3. verify BEFORE you delete anything
MATCH (:Account)-[r:TRANSACTED]->(:Account)
WITH count(r) AS oldEdges
MATCH (t:Transaction)
RETURN oldEdges, count(t) AS newNodes, oldEdges = count(t) AS matches;
// 4. only now, drop the old relationships
MATCH ()-[r:TRANSACTED]->()
WHERE r.migrated = true
CALL (r) { DELETE r } IN TRANSACTIONS OF 10000 ROWS;

Four separate statements, in that order, with a human reading the output of step 3. Every destructive refactor we do for clients has that shape: create the new structure, dual-write or backfill, verify counts, then remove the old — never a single statement that does all four.

And take a backup first. Not a snapshot you assume works — one you have restored. If you have not drilled that, see backups you can actually restore.

Step 5 — prove the refactor worked

A refactor you cannot measure is a refactor you cannot defend in a code review. Capture the plan and timing of your three worst queries before the change:

PROFILE
MATCH (a:Account {iban: $iban})-[r:TRANSACTED]->(b:Account)
WHERE r.ts > datetime() - duration('P30D')
RETURN b.iban, r.amount;

Record db hits and rows from the Expand(All) operator. After the refactor, the equivalent query is:

PROFILE
MATCH (a:Account {iban: $iban})-[:SENT]->(t:Transaction)-[:RECEIVED_BY]->(b:Account)
WHERE t.ts > datetime() - duration('P30D')
RETURN b.iban, t.amount;

You are looking for two things: total db hits down by an order of magnitude, and — the real prize — a plan whose cost no longer scales with the degree of the hub node. Then wire both queries into your migration test suite so the model cannot silently regress; the Testcontainers setup from our CI tutorial is exactly the place for that assertion.

A checklist you can run quarterly

  • No relationship type name contains a date, status, or other value
  • No node on a hot traversal path has degree > 100,000
  • No hot query filters on a relationship property
  • No symmetric relationship is stored twice
  • Every property used in a WHERE equality or range has an index
  • Every entity that has a natural key has a uniqueness constraint
  • The current model is drawn somewhere a new developer can find it

Six of those seven are a single Cypher query away. The seventh is the one that decays fastest.

Where this usually ends up

Model debt compounds quietly: each new feature adds a hop to a query that was already too slow, until someone proposes leaving Neo4j entirely for a problem that is genuinely a graph problem. Almost always, two or three structural changes of the kind above return the graph to sub-100ms — and they are cheaper the earlier you make them.

If you would like a second pair of eyes on your model before it hits that point, our senior Neo4j consultants do focused modelling reviews: we profile your workload, quantify the skew, and hand back a prioritised refactor plan with the migration Cypher written. Get in touch with a rough shape of your graph — labels, volumes, and the three queries that hurt — and we will tell you what we would change first.