Chapter 14 · Asynchronous, Reactive, Batch, and High-Throughput Application Patterns

N+1 Graph Queries, Chatty Traversals, Projection Design, and Returning Only Needed Data

Replace chatty AtlasMart graph access with bounded connected queries and explicit projections while measuring round trips, cardinality and result bytes.

Advanced170–220 minutesN+1 and projection-shaping labNeo4j 2026.07.1 Community · Cypher 25Python driver 6.3.0 · asyncOptional JS driver 6.2.0 · Reactive APILast reviewed: September 2026

Learning outcomes

AtlasMart’s order page first asks for 50 orders, then loops over them and issues one query per order for products, then another query per product for supplier data. The database may execute every query quickly, yet the page is slow because hundreds of Bolt round trips, pool borrows and serializations accumulate. This is the graph version of the N+1 problem: a client traversal encoded as chatty network calls.

01

Recognize N+1 and chatty traversal patterns in graph application code.

02

Push connected-data traversal into a bounded parameterized Cypher query when it preserves API semantics.

03

Shape projections so the client receives only fields and cardinality it owns.

04

Compare one large query with many tiny queries using round trips, result bytes and server plan evidence.

05

Avoid replacing N+1 with an unbounded collect/map payload that merely moves the bottleneck.

Chapter 14 baseline · reviewed 9 September 2026

The mandatory lab continues Neo4j Community 2026.07.1, database neo4j, explicit CYPHER 25 where language-version behavior matters, container atlasmart-neo4j, loopback Bolt bolt://127.0.0.1:7687, authentication neo4j/atlasmart-course-2026, and no TLS on the disposable loopback-only instance. The primary client is the official Python package neo4j 6.3.0 on Python 3.10–3.14. The optional Reactive Streams exercise uses the official JavaScript driver neo4j-driver 6.2.0; its lite package deliberately omits the Reactive API. Neo4j 5.26.30 remains the LTS comparison line.

Evidence boundary

This generation environment does not connect to your AtlasMart Neo4j container, so no throughput, p95/p99, pool-queue, CPU, page-cache, I/O, cancellation, GC, routing or network measurements are fabricated. The chapter provides deterministic fixtures, runnable harnesses, measurement columns and acceptance invariants. Values such as concurrency, fetch size and batch size are experimental variables—not universal recommendations.

Reproducible AtlasMart setup

PowerShell · environment
$env:NEO4J_URI='bolt://127.0.0.1:7687'$env:NEO4J_USER='neo4j'$env:NEO4J_PASSWORD='atlasmart-course-2026'$env:NEO4J_DATABASE='neo4j'py -3 -m venv .venv.\.venv\Scripts\Activate.ps1python -m pip install --upgrade pippython -m pip install neo4j==6.3.0
Cypher · stable fixture and constraints
CYPHER 25CREATE CONSTRAINT customer_id IF NOT EXISTSFOR (c:Customer) REQUIRE c.customerId IS UNIQUE;CREATE CONSTRAINT product_id IF NOT EXISTSFOR (p:Product) REQUIRE p.productId IS UNIQUE;CREATE CONSTRAINT order_id IF NOT EXISTSFOR (o:Order) REQUIRE o.orderId IS UNIQUE;MERGE (c:Customer {customerId:'C-1001'})SET c.name='Mina Rahimi', c.tier='GOLD'MERGE (p1:Product {productId:'P-1001'})SET p1.name='Trail Camera', p1.category='Cameras', p1.price=129.90MERGE (p2:Product {productId:'P-2001'})SET p2.name='Smart Shelf Sensor', p2.category='Store IoT', p2.price=79.50MERGE (o:Order {orderId:'O-5001'})SET o.status='PAID', o.orderedAt=datetime('2026-09-08T16:30:00Z')MERGE (c)-[:PLACED]->(o)MERGE (o)-[r1:CONTAINS]->(p1)SET r1.quantity=1, r1.unitPrice=129.90MERGE (o)-[r2:CONTAINS]->(p2)SET r2.quantity=2, r2.unitPrice=79.50;

The fixture is intentionally tiny. Performance conclusions come from the synthetic workload generated by the harness, not from pretending two products are representative of production. The stable keys and uniqueness constraints make repeated batch-write experiments safe to reconcile.

1. N+1 is a request-shape problem

Python · deliberately chatty N+1
async def wrong_order_feed(driver, customer_id):    records, _, _ = await driver.execute_query(        "CYPHER 25 MATCH (:Customer {customerId:$id})-[:PLACED]->(o:Order) RETURN o.orderId AS id",        id=customer_id, database_="neo4j")    feed = []    for row in records:        lines, _, _ = await driver.execute_query(            "CYPHER 25 MATCH (:Order {orderId:$id})-[r:CONTAINS]->(p:Product) RETURN p, r.quantity AS q",            id=row["id"], database_="neo4j")        feed.append({"orderId": row["id"], "lines": [x.data() for x in lines]})    return feed

For N orders this is at least N+1 database requests. Async parallelism can hide some wall-clock waiting but increases concurrent load; it does not erase network handshakes, pool pressure or duplicate planning/execution work.

2. One bounded traversal + projection

Cypher · return application-owned maps, not raw graph objects
CYPHER 25MATCH (:Customer {customerId:$customerId})-[:PLACED]->(o:Order)OPTIONAL MATCH (o)-[r:CONTAINS]->(p:Product)WITH o,     collect(CASE WHEN p IS NULL THEN null ELSE {       productId:p.productId,       name:p.name,       quantity:r.quantity,       unitPrice:r.unitPrice     } END) AS maybeLinesRETURN {  orderId:o.orderId,  status:o.status,  orderedAt:o.orderedAt,  lines:[x IN maybeLines WHERE x IS NOT NULL]} AS orderORDER BY order.orderedAt DESC, order.orderIdLIMIT $limit;
Python · one parameterized read
async def order_feed(driver, customer_id, limit=50):    records, summary, _ = await driver.execute_query(        QUERY,        customerId=customer_id,        limit=limit,        database_="neo4j",        routing_="r",    )    return [r["order"] for r in records], summary

The query has an explicit API bound ($limit) and returns a stable DTO-like map. Returning entire nodes/relationships makes payload size and accidental field disclosure track graph evolution rather than API ownership.

3. Compare causal evidence, not syntax

Metric N+1 variant Shaped-query variant
Bolt query count 1 + number of returned orders 1
pool borrows / queued requests many, possibly concurrent one request scope
server row work distributed across many plans/executions one plan; may still fan out
result bytes can overfetch raw nodes repeatedly explicit fields; measure actual bytes
failure surface partial feed after one child query fails single read transaction/query result boundary
maintainability application owns graph traversal loop Cypher owns connected traversal; application owns response contract

Do not assume the one-query version is automatically fastest. Profile it with realistic graph density. A single query that accidentally traverses millions of relationships can be worse than a deliberately segmented API. The design objective is minimal necessary round trips with bounded server cardinality.

4. Wrong repair: collect the whole subgraph

Cypher · too much projection
// WRONG for an API feed: no customer/page bound and raw entities.CYPHER 25MATCH (o:Order)-[r:CONTAINS]->(p:Product)RETURN collect({order:o, rel:r, product:p}) AS everything;

This hides chatty access by creating one giant response. It can consume query/client memory, serialize properties the API never uses and create security coupling to future graph properties. The repair is to bound the anchor, project needed scalar/map fields and paginate with a stable order.

5. Result-shaping checklist

Question Preferred evidence
How many top-level entities can one call return? explicit LIMIT/cursor contract + test
How many relationships can each anchor expand through? degree distribution and PROFILE rows
Which properties cross the API boundary? explicit projection schema, not RETURN n
How many Bolt requests serve one API request? request-correlated counter/log
How many bytes are serialized? measure JSON/MessagePack response bytes
Can absent children preserve parent rows? OPTIONAL MATCH test fixture
Can a tenant traverse another tenant? authorization predicate + negative test

6. Mini benchmark harness

Python · count calls and response bytes
import json, timeasync def measure(label, fn):    t0 = time.perf_counter()    value = await fn()    elapsed = (time.perf_counter() - t0) * 1000    payload = json.dumps(value, default=str, separators=(",", ":")).encode()    return {"variant":label, "elapsed_ms":elapsed, "result_bytes":len(payload)}# Run each variant repeatedly after warmup with the same customer/page size.# Also count actual driver query calls in your repository instrumentation.

Production judgment

Review area Decision evidence
Graph/workload fit fan-out/degree, result cardinality and traversal shape; avoid making driver concurrency compensate for a poor model
Correctness stable keys, constraints, transaction boundaries and idempotent retry semantics
Latency p50/p95/p99 plus timeout/error/cancellation rate—not average latency alone
Client resources event-loop queue, connection-pool wait, result bytes, process RSS/GC and serialization time
Server resources CPU, page cache, store I/O, active/queued transactions, locks and query memory where observable
Security parameterized Cypher, tenant authorization, TLS/auth policy and bounded user-controlled result sizes
Recovery/rollback batch checkpoint/reconciliation, stable IDs, bounded blast radius and restartable jobs
Edition/topology Community lab is single-instance; Aura/Enterprise routing and managed-service limits require separate evidence

Check your understanding

  1. Why does asyncio not eliminate N+1?
  2. Why return maps/scalars instead of raw nodes?
  3. Can one query be worse than N+1?
  4. What makes pagination part of performance design?
  5. What should be counted per API request?
Review the answers

1. It can overlap waits, but the extra Bolt requests, pool usage, planning/execution and serialization remain.

2. It keeps the API schema explicit, limits bytes and avoids accidental property exposure/coupling.

3. Yes, if it creates unbounded fan-out or a huge collect; compare plan cardinality and bytes.

4. It bounds result cardinality/bytes and gives a stable request contract under load.

5. At least database query count, result bytes, end-to-end latency and errors; ideally correlate server evidence too.

Summary and next step

High-throughput graph services reduce avoidable round trips and return bounded application-owned projections. Lesson 5 puts all client/server stages on one measurement timeline so “database time” is not blamed for everything.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.