Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
neo4j-contrib avatar

Neo4j Driver Javascript Skill

  • 1 installs
  • 101 repo stars
  • Updated August 3, 2026
  • neo4j-contrib/neo4j-skills

Reference for the official Neo4j JavaScript/TypeScript driver v6 covering driver lifecycle, query APIs, Integer handling, transactions, result consumption, and browser/WebSocket usage.

About

Guides writing JavaScript or TypeScript code against Neo4j using the v6 driver, covering executeQuery, managed read/write transactions, session.run, Integer and temporal types, and async error handling. A developer uses it when connecting a Node.js or browser app to Neo4j, not for authoring Cypher.

  • Covers all three query APIs plus neo4j.int Integer handling and result consumption
  • Single shared driver lifecycle guidance and neo4j+s Aura URI schemes

Neo4j Driver Javascript Skill by the numbers

  • 1 all-time installs (skills.sh)
  • Ranked #770 of 911 Databases skills by installs in the Skillselion catalog
  • Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/neo4j-contrib/neo4j-skills --skill neo4j-driver-javascript-skill

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs1
repo stars101
Last updatedAugust 3, 2026
Repositoryneo4j-contrib/neo4j-skills

What it does

Reference for the official Neo4j JavaScript/TypeScript driver v6 covering driver lifecycle, query APIs, Integer handling, transactions, result consumption, and browser/WebSocket usage.

Files

SKILL.mdMarkdownGitHub ↗

Neo4j JavaScript Driver

Package: neo4j-driver Current stable: v6 Docs: https://neo4j.com/docs/javascript-manual/current/ API ref: https://neo4j.com/docs/api/javascript-driver/current/

---

When to Use

  • Writing JavaScript or TypeScript code that connects to Neo4j
  • Setting up neo4j.driver(), executeQuery(), or session patterns in Node.js or the browser
  • Questions about Integer handling (neo4j.int), temporal types, async/Promise patterns, or TypeScript typing
  • Debugging result consumption, session closure, or browser/WebSocket usage

When NOT to Use

  • Writing or optimizing Cypher queries → use neo4j-cypher-skill
  • Upgrading from an older driver version → use neo4j-migration-skill

---

1. Installation

# Node.js
npm install neo4j-driver

# or with yarn
yarn add neo4j-driver

For browser usage see section 12 — a different URI scheme is required.

---

2. Driver Lifecycle

Driver is thread-safe, connection-pooled, and expensive to create — create exactly one instance per application and share it everywhere. Close it on application shutdown.

const neo4j = require('neo4j-driver')         // CommonJS
// import neo4j from 'neo4j-driver'           // ESM / TypeScript

// URI examples:
//   'neo4j://localhost'                — unencrypted, cluster-routing
//   'neo4j+s://xxx.databases.neo4j.io' — TLS, cluster-routing (Aura)
//   'bolt://localhost:7687'            — unencrypted, single instance
//   'bolt+s://localhost:7687'          — TLS, single instance
const URI      = 'neo4j+s://xxx.databases.neo4j.io'
const USER     = 'neo4j'
const PASSWORD = 'password'

const driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD))

// Verify connection at startup — fail fast if unreachable
await driver.getServerInfo()

// On application shutdown:
await driver.close()

Auth Options

neo4j.auth.basic(user, password)          // username + password
neo4j.auth.bearer(token)                  // SSO / JWT
neo4j.auth.kerberos(base64Ticket)         // Kerberos
neo4j.auth.none()                         // unauthenticated (dev only)

Singleton Pattern — Web Frameworks

// db.js — create once, import everywhere
import neo4j from 'neo4j-driver'

let _driver = null

export function getDriver() {
  if (!_driver) {
    _driver = neo4j.driver(
      process.env.NEO4J_URI,
      neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD)
    )
  }
  return _driver
}

export async function closeDriver() {
  if (_driver) {
    await _driver.close()
    _driver = null
  }
}

// In Express: call closeDriver() on SIGTERM
// In Next.js: export getDriver() and call in API routes

⚠ Serverless Environments (Lambda, Vercel, Cloudflare Workers)

The singleton pattern above assumes a long-lived process. Serverless functions have a different execution model that changes how the driver should be managed:

  • Cold starts: each new function instance creates a fresh module scope, so _driver starts as null and a new Driver is created — incurring the connection pool setup and TLS handshake cost on every cold start.
  • Warm reuse: within the same warm instance, the module-level _driver persists between invocations, so the pool is reused (this is the intended benefit).
  • Connection pool sizing: a serverless function that runs many concurrent invocations spawns many separate instances, each with its own pool. maxConnectionPoolSize should be kept small (e.g. 5–10) to avoid overwhelming the database with connections.
  • No guaranteed shutdown: SIGTERM handlers may not fire in all serverless runtimes, so closeDriver() may never be called. Design for connections being dropped by the platform rather than cleanly closed.
// Serverless-appropriate driver config
const driver = neo4j.driver(URI, auth, {
  maxConnectionPoolSize: 5,              // small pool per function instance
  connectionAcquisitionTimeout: 5000,   // fail fast rather than queue up
  maxConnectionLifetime: 300000,        // 5 min — shorter than typical Lambda timeout
})

---

3. Choosing the Right API

APIWhen to useAuto-retry?Streaming?
driver.executeQuery()Most queries — simple, safe default❌ (eager)
session.executeRead/Write()Large results, lazy streaming, complex logic
session.run()LOAD CSV, CALL {} IN TRANSACTIONS, scripting

---

4. executeQuery — Recommended Default

The highest-level API. Manages sessions, transactions, retries, and bookmarks automatically.

// Read query
const { records, summary, keys } = await driver.executeQuery(
  'MATCH (p:Person {name: $name})-[:KNOWS]->(friend) RETURN friend.name AS name',
  { name: 'Alice' },                         // parameters — second positional arg
  {
    database: 'neo4j',                        // always specify — avoids a round-trip
    routing: neo4j.routing.READ,              // route reads to replicas
  }
)

for (const record of records) {
  console.log(record.get('name'))             // ✅ use .get() — see section 9
}

console.log(`Returned ${records.length} records in ${summary.resultAvailableAfter} ms`)
console.log(`Keys: ${keys}`)                  // ['name']

// Write query
const { summary: writeSummary } = await driver.executeQuery(
  'CREATE (p:Person {name: $name, age: $age})',
  { name: 'Bob', age: 30 },
  { database: 'neo4j' }
)
console.log(`Created ${writeSummary.counters.updates().nodesCreated} nodes`)

Reading Query Counters — .updates() Is Required

summary.counters is not a flat object — you must call .updates() to get the statistics object. Accessing properties directly on counters returns undefined silently:

// ❌ Wrong — undefined silently, no error thrown
summary.counters.nodesCreated        // undefined
summary.counters.relationshipsCreated // undefined

// ✅ Correct — call .updates() first
const stats = summary.counters.updates()
stats.nodesCreated                   // number
stats.nodesDeleted                   // number
stats.relationshipsCreated           // number
stats.relationshipsDeleted           // number
stats.propertiesSet                  // number
stats.labelsAdded                    // number

// ✅ Or inline:
summary.counters.updates().nodesCreated

// Two timing properties also exist on summary directly (no .updates() needed):
summary.resultAvailableAfter   // ms until first record was available from server
summary.resultConsumedAfter    // ms until all records were consumed — use this for
                               // total query wall-clock time in your app

### `executeQuery` Argument Shape

driver.executeQuery( query, // string — Cypher parameters, // object — $param values (pass {} if none, not omitted) config // object — { database, routing, resultTransformer, bookmarkManager, auth } )


**⚠ Never template-literal or concatenate Cypher.** Always use `$param` placeholders — prevents injection and enables server-side query plan caching.

// ❌ Injection risk and disables plan caching const name = req.body.name await driver.executeQuery(MATCH (p:Person {name: '${name}'}) RETURN p)

// ✅ Parameterised await driver.executeQuery('MATCH (p:Person {name: $name}) RETURN p', { name })


### Result Transformers

// Built-in: map records to custom objects const people = await driver.executeQuery( 'MATCH (p:Person) RETURN p.name AS name, p.age AS age', {}, { database: 'neo4j', resultTransformer: neo4j.resultTransformers.mappedResultTransformer({ map(record) { return { name: record.get('name'), age: record.get('age').toNumber() } }, collect(mapped) { return mapped // array of mapped objects } }) } ) // people is whatever collect() returns — no need to unpack { records }


---

## 5. Managed Transactions (`executeRead` / `executeWrite`)

Use when you need lazy result streaming or want to run multiple queries inside one transaction.

const session = driver.session({ database: 'neo4j' }) try { // Read — routes to replicas; callback auto-retried on transient failure const names = await session.executeRead(async tx => { const result = await tx.run( 'MATCH (p:Person) WHERE p.name STARTS WITH $prefix RETURN p.name AS name', { prefix: 'Al' } ) // ✅ Consume records INSIDE the callback — result is a stream tied to the tx return result.records.map(r => r.get('name')) })

// Write — routes to leader await session.executeWrite(async tx => { await tx.run('CREATE (p:Person {name: $name})', { name: 'Carol' }) }) } finally { await session.close() // ✅ always close in finally — see section on async error paths }


### Critical: `result` vs `result.records` in a Callback

Inside a managed transaction callback, `tx.run()` returns a `Result` — a **lazy stream** object, not an array. Records are fetched from the server as you consume the stream. `result.records` is only populated after the stream has been fully consumed by iterating, calling `.collect()`, or similar. The transaction closes the moment the callback's Promise resolves, after which the stream is gone.

// ❌ WRONG — returns the un-awaited Result promise; transaction closes immediately, // stream is destroyed, result.records will be [] const badResult = await session.executeRead(async tx => { return tx.run('MATCH (p:Person) RETURN p.name AS name') }) console.log(badResult.records) // [] — cursor closed before any records were fetched

// ❌ ALSO WRONG — awaiting tx.run() gives you the Result stream object, // but does NOT fetch any records. result.records is still [] at this point. // Returning the stream out of the callback means it will be consumed AFTER // the transaction closes — records will be empty or the stream will error. const alsoWrong = await session.executeRead(async tx => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name') // result.records is [] here — awaiting tx.run() only gives you the stream object, // it does not eagerly fetch records return result // stream returned; tx closes; stream is dead }) console.log(alsoWrong.records) // [] — never fetched

// ✅ CORRECT — consume the stream fully inside the callback using .collect(), // then return plain data const names = await session.executeRead(async tx => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name') const records = await result.collect() // ← fetches all records while tx is open return records.map(r => r.get('name')) // plain array — safe to return })

// ✅ ALSO CORRECT — result.records is populated after subscribing/consuming via for-await const names2 = await session.executeRead(async tx => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name') const names = [] for await (const record of result) { // ← streams records while tx is open names.push(record.get('name')) } return names })


**The key mental model**: `await tx.run()` gives you a stream handle, not the data. You must consume the stream (`.collect()`, `for await`, or `.subscribe()`) while still inside the callback. Any data you want to use after the callback must be extracted into a plain JS array or object before returning.

### Multiple `tx.run()` Calls

Each `await tx.run(...)` gives you a stream. Consume it with `.collect()` or `for await` before starting the next run — otherwise the first stream is implicitly consumed in one go by the driver when the second query starts, which can pull a large result unexpectedly into memory:

const count = await session.executeWrite(async tx => { // First run — consume with .collect() before the second run const peopleResult = await tx.run('MATCH (p:Person) RETURN p.name AS name') const names = (await peopleResult.collect()).map(r => r.get('name'))

// Second run — safe, first stream is fully consumed for (const name of names) { await tx.run( 'MERGE (p:Person {name: $name})-[:VISITED]->(:City {name: $city})', { name, city: 'London' } ) }

return names.length })


### Retry Safety

The callback **may execute more than once** on transient failures. Keep callbacks idempotent:

// ❌ Side effect fires on every retry attempt await session.executeWrite(async tx => { await fetch('https://api.example.com/notify') // called on every retry! await tx.run('CREATE (p:Person {name: $name})', { name: 'Alice' }) })

// ✅ Database work only inside the callback; side effects outside await session.executeWrite(async tx => { await tx.run('MERGE (p:Person {name: $name})', { name: 'Alice' }) // MERGE is idempotent }) await fetch('https://api.example.com/notify') // only runs once, after confirmed commit


### TransactionConfig — Timeouts and Metadata

await session.executeRead( async tx => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name') return result.records.map(r => r.get('name')) }, { timeout: 5000, // milliseconds metadata: { app: 'myService', user: userId } // visible in SHOW TRANSACTIONS } )


---

## 6. Implicit Transactions (`session.run`)

Lowest-level API — **not automatically retried**. Use only for:
- `LOAD CSV` imports
- `CALL { } IN TRANSACTIONS` Cypher
- Quick scripting

const session = driver.session({ database: 'neo4j' }) try { const result = await session.run( 'CREATE (p:Person {name: $name}) RETURN p', { name: 'Alice' } ) const summary = result.summary console.log(Created ${summary.counters.updates().nodesCreated} nodes) } finally { await session.close() }


Since the driver cannot determine read/write intent from `session.run()`, it defaults to write mode. For read-only implicit transactions:

const session = driver.session({ database: 'neo4j', defaultAccessMode: neo4j.session.READ })


---

## 7. Explicit Transactions

Use when a transaction must span multiple functions or coordinate with external systems. **Not automatically retried.**

const session = driver.session({ database: 'neo4j' }) const tx = await session.beginTransaction() try { await doPartA(tx) await doPartB(tx) await tx.commit() } catch (err) { await tx.rollback() // rollback can itself throw — see below throw err } finally { await session.close() }


### Rollback Can Throw

`tx.rollback()` is a network call. If the server is unreachable, it rejects. Don't let it swallow the original error:

} catch (err) { try { await tx.rollback() } catch (rollbackErr) { // Log the rollback failure but re-throw the original error console.error('Rollback failed:', rollbackErr) } throw err // original error still propagates }


### Commit Uncertainty

If `tx.commit()` rejects with a network error, the commit may or may not have succeeded. Design writes to be idempotent using `MERGE` and unique constraints so retrying is safe.

---

## 8. Async Error Handling and Session Closure

Session closure in error paths is the most common resource leak in JavaScript driver code. **Always close sessions in a `finally` block**, not in `.then()` chains, which silently swallow exceptions from the session work itself:

// ❌ Wrong — if the executeRead rejects, the .then() never runs; session leaks session.executeRead(async tx => { ... }) .then(result => doSomething(result)) .then(() => session.close()) // never reached on error

// ❌ Also wrong — catch closes the session but re-throw is missing session.executeRead(async tx => { ... }) .catch(err => { session.close() // forgot to rethrow — error is swallowed })

// ✅ Correct — try/finally guarantees closure regardless of success or failure try { const result = await session.executeRead(async tx => { ... }) doSomething(result) } catch (err) { handleError(err) } finally { await session.close() // always runs }


### Promise Chain Pattern (non-async/await contexts)

// When you must use .then() chains, attach .finally() for cleanup session.executeRead(async tx => { ... }) .then(result => doSomething(result)) .catch(err => handleError(err)) .finally(() => session.close()) // ✅ always runs


---

## 9. Integer Handling — JavaScript-Specific Complexity

This is the most JavaScript-specific aspect of the driver. Neo4j stores integers as 64-bit values. JavaScript's `Number` type is IEEE 754 double-precision, so it can only represent integers exactly up to `Number.MAX_SAFE_INTEGER` (2^53 − 1 = 9,007,199,254,740,991). Values above this lose precision silently.

The driver returns Neo4j integers as a custom **`Integer` class** (not a JS `number`) by default. This prevents silent precision loss but requires explicit conversion.

### The Three Integer Modes

// Mode 1 (default): Custom Integer class — safe for all values, requires conversion const driver1 = neo4j.driver(URI, auth) // record.get('count') returns Integer { low: 42, high: 0 }

// Mode 2: disableLosslessIntegers — returns native JS number // Safe ONLY if you know values will be within Number.MAX_SAFE_INTEGER const driver2 = neo4j.driver(URI, auth, { disableLosslessIntegers: true }) // record.get('count') returns 42 (JS number) — ✅ but loses precision for large values

// Mode 3: useBigInt — returns native BigInt // Precise for all values, but BigInt breaks JSON.stringify (see below) const driver3 = neo4j.driver(URI, auth, { useBigInt: true }) // record.get('count') returns 42n (BigInt)


### Working with the Default `Integer` Class

const count = record.get('count') // Integer { low: 42, high: 0 }

neo4j.isInt(count) // true — check if it's a driver Integer

// Convert to JS number (only safe within Number.MAX_SAFE_INTEGER) if (neo4j.integer.inSafeRange(count)) { const n = count.toNumber() // 42 } else { const s = count.toString() // '9223372036854775807' — use string for large values }

// Convert to BigInt (always safe, any size) const b = count.toBigInt() // 42n

// Arithmetic with Integer class const doubled = count.multiply(neo4j.int(2)) // Integer const added = count.add(10) // Integer

// Send an integer as a parameter await driver.executeQuery( 'CREATE (p:Person {age: $age})', { age: neo4j.int(30) }, // wraps 30 in Integer class { database: 'neo4j' } ) // ⚠ plain JS Number is sent as Cypher FLOAT, not INTEGER: // { id: 12345 } → server receives 12345.0 — use neo4j.int() for integer parameters


### Integer and JSON Serialization

The `Integer` class **does not serialize to JSON correctly** — it will produce `{"low":42,"high":0}` instead of `42`:

// ❌ Broken JSON output const record = records[0] const age = record.get('age') // Integer { low: 30, high: 0 } JSON.stringify({ age }) // '{"age":{"low":30,"high":0}}' — NOT what you want

// ❌ BigInt mode also breaks JSON.stringify // JSON.stringify(42n) throws TypeError: Do not know how to serialize a BigInt

// ✅ Convert before serializing const age = record.get('age').toNumber() // 30 JSON.stringify({ age }) // '{"age":30}'

// ✅ Or use disableLosslessIntegers if your data is within safe range const driver = neo4j.driver(URI, auth, { disableLosslessIntegers: true }) // All returned integers are now plain JS numbers — JSON.stringify works directly

// ✅ Or add a BigInt JSON replacer if you use useBigInt BigInt.prototype.toJSON = function() { return this.toString() } // Note: modifying built-in prototypes is generally discouraged


### Helper Functions

const { isInt, integer, int } = neo4j

isInt(value) // true if value is a driver Integer integer.inSafeRange(value) // true if safe to call .toNumber() integer.toNumber(value) // convert Integer to JS number integer.toString(value) // convert Integer to string int(42) // create an Integer from a JS number or string int('9223372036854775807') // create a large Integer from string (safe)


---

## 10. Record Access

### `.get()` Is Mandatory

Records are not plain JavaScript objects. You **cannot** access values with dot notation or bracket notation — you must use `.get()`:

const record = records[0]

// ❌ Undefined — these do NOT work record.name record['name']

// ✅ Correct record.get('name') // by key (string) record.get(0) // by index (0-based)

// Other record methods record.keys // ['name', 'age'] — array of projected keys record.has('name') // true if key was projected


### `record.toObject()` Is Not JSON-Safe

`record.toObject()` returns a plain JS object keyed by column name — but **the values are still driver types** (Integers, temporals, Nodes, etc.). It is not a shortcut to a JSON-serializable object:

// Query: MATCH (p:Person) RETURN p.name AS name, p.age AS age const obj = record.toObject() // obj == { name: 'Alice', age: Integer { low: 30, high: 0 } } // ^^^^ still a driver Integer, not a JS number

// ❌ Broken JSON output — same failure as calling .get('age') directly JSON.stringify(obj) // '{"name":"Alice","age":{"low":30,"high":0}}' — NOT what you want

// ✅ Map the values yourself while converting const plain = { name: record.get('name'), // string — fine age: record.get('age').toNumber() // Integer → number } JSON.stringify(plain) // '{"name":"Alice","age":30}'

// ✅ Or project scalar fields in Cypher so .toObject() is safe: // MATCH (p:Person) RETURN p.name AS name, toInteger(p.age) AS age // — but note: Cypher toInteger() still returns a Neo4j Integer in JS; // the only fully safe approach is .toNumber() on the JS side, // or using disableLosslessIntegers: true on the driver

// ✅ Or use the toNative() helper from section 11 on the raw object: const safe = Object.fromEntries( Object.entries(record.toObject()).map(([k, v]) => [k, toNative(v)]) )


### Null Safety — Absent Key vs Graph Null

// record.get() on a key that was never projected throws: record.get('typo') // throws Neo4jError: This record has no field with key 'typo'

// record.get() on a key projected as null (e.g. from OPTIONAL MATCH) returns null: // Query: OPTIONAL MATCH (p)-[:LIVES_IN]->(c:City) RETURN p.name AS name, c.name AS city record.get('city') // null when no City was matched

// Check before accessing: if (record.has('city') && record.get('city') !== null) { const city = record.get('city') }


---

## 11. Data Types

### Cypher → JavaScript Type Mapping

| Cypher type | JS type (default) | JS type (`disableLosslessIntegers`) |
|-------------|------------------|-------------------------------------|
| `Integer` | `neo4j.Integer` | `number` |
| `Float` | `number` | `number` |
| `String` | `string` | `string` |
| `Boolean` | `boolean` | `boolean` |
| `List` | `Array` | `Array` |
| `Map` | `Object` | `Object` |
| `Node` | `neo4j.types.Node` | `neo4j.types.Node` |
| `Relationship` | `neo4j.types.Relationship` | `neo4j.types.Relationship` |
| `Path` | `neo4j.types.Path` | `neo4j.types.Path` |
| `Date` | `neo4j.types.Date` | `neo4j.types.Date` |
| `DateTime` | `neo4j.types.DateTime` | `neo4j.types.DateTime` |
| `Duration` | `neo4j.types.Duration` | `neo4j.types.Duration` |
| `Point` | `neo4j.types.Point` | `neo4j.types.Point` |
| `null` | `null` | `null` |

### Graph Types

// Node const node = record.get('p') // neo4j.types.Node node.labels // ['Person'] node.properties // { name: 'Alice', age: Integer{...} } node.properties.name // 'Alice' node.properties.age.toNumber() // 30 node.elementId // '4:uuid:393' — use this, not .identity (deprecated)

// Relationship const rel = record.get('r') // neo4j.types.Relationship rel.type // 'KNOWS' rel.properties.since // Integer or driver temporal type rel.startNodeElementId rel.endNodeElementId

// ⚠ elementId is only stable within one transaction. // Do not use it to MATCH entities across separate transactions.


### Temporal Types

Neo4j temporal types are **not** native JS `Date` objects. They have nanosecond precision and support timezone IDs that JS `Date` doesn't.

const dt = record.get('created_at') // neo4j.types.DateTime dt.toString() // '2024-01-15T10:30:00.000000000+00:00' — ISO 8601

// Convert to JS Date (lossy — drops nanoseconds, may lose timezone precision) const jsDate = dt.toStandardDate() // Date object

// Create from JS Date const neo4jDt = neo4j.types.DateTime.fromStandardDate(new Date())

// Pass native JS Date as parameter — driver converts automatically await driver.executeQuery( 'CREATE (e:Event {at: $ts})', { ts: new Date() }, { database: 'neo4j' } )

// Temporal types also don't JSON.stringify correctly: JSON.stringify(dt) // '{}' — empty object, silent failure // ✅ Use toString() before serializing JSON.stringify({ created: dt.toString() })


### A Practical Type-Conversion Helper

For REST APIs that need to serialize all driver types to plain JS, a conversion helper avoids scattered `.toNumber()` and `.toString()` calls. **Call it on `.properties`, not on the Node or Relationship object itself** — or add explicit Node/Relationship handling as shown:

import { isInt, isDate, isDateTime, isTime, isLocalDateTime, isLocalTime, isDuration, isPoint, isNode, isRelationship, isPath } from 'neo4j-driver'

function toNative(value) { if (value === null || value === undefined) return value if (Array.isArray(value)) return value.map(toNative) if (isInt(value)) return value.inSafeRange() ? value.toNumber() : value.toString() if (isDate(value) || isDateTime(value) || isTime(value) || isLocalDateTime(value) || isLocalTime(value) || isDuration(value)) return value.toString() if (isPoint(value)) return { x: toNative(value.x), y: toNative(value.y), z: toNative(value.z), srid: toNative(value.srid) } // ✅ Handle Node and Relationship explicitly — without this, // passing a Node to toNative() traverses its raw fields (labels, identity, elementId) // instead of just the properties, producing a structurally wrong output if (isNode(value)) return { labels: value.labels, properties: toNative(value.properties) } if (isRelationship(value)) return { type: value.type, properties: toNative(value.properties), startNodeElementId: value.startNodeElementId, endNodeElementId: value.endNodeElementId } if (typeof value === 'object') return Object.fromEntries( Object.entries(value).map(([k, v]) => [k, toNative(v)]) ) return value }

// ✅ Safe to call on a whole Node const person = toNative(record.get('p')) // { labels: ['Person'], properties: { name: 'Alice', age: 30 } }

// ✅ Or just on the properties if you don't need labels const props = toNative(record.get('p').properties) // { name: 'Alice', age: 30 }

JSON.stringify(props) // ✅ works — all driver types converted to primitives


**What NOT to do:**

// ❌ Passing a Node to the previous (incomplete) version of toNative() // would hit the generic typeof value === 'object' branch and traverse // Node's own fields: { identity: Integer, labels: [...], properties: {...}, // elementId: '...' } — the Integer fields within would be converted, but // the output shape is unexpected and includes internal driver fields


---

## 12. Performance

### Always Specify the Database

Omitting `database` causes an extra round-trip to resolve the home database on every call:

// executeQuery: await driver.executeQuery(query, params, { database: 'neo4j' })

// Session: driver.session({ database: 'neo4j' })


### Route Reads to Replicas

// executeQuery: await driver.executeQuery(query, params, { database: 'neo4j', routing: neo4j.routing.READ })

// Managed transaction — executeRead routes automatically: session.executeRead(async tx => { ... })


### Batch Writes with `UNWIND`

Pass an array of plain objects — each element becomes one row in the Cypher loop:

// ❌ One transaction per item — high overhead for (const person of people) { await driver.executeQuery( 'CREATE (p:Person {name: $name, age: $age})', { name: person.name, age: person.age }, { database: 'neo4j' } ) }

// ✅ Single transaction via UNWIND — people is an array of plain objects const people = [ { name: 'Alice', age: 30, city: 'London' }, { name: 'Bob', age: 25, city: 'Paris' }, ]

await driver.executeQuery( UNWIND $people AS person MERGE (p:Person {name: person.name}) SET p.age = person.age MERGE (c:City {name: person.city}) MERGE (p)-[:LIVES_IN]->(c), { people }, { database: 'neo4j' } )

// ⚠ Integer values inside the array objects must be plain JS numbers or neo4j.int(), // not driver Integer instances (which don't serialize through the parameter layer correctly // when nested inside plain objects): const rows = people.map(p => ({ ...p, age: Number(p.age) }))


### Lazy Streaming for Large Results

// executeQuery is always eager — fine for small/medium results const { records } = await driver.executeQuery('MATCH (p:Person) RETURN p', {}, { database: 'neo4j' })

// For large results, stream lazily using for-await inside a managed transaction const session = driver.session({ database: 'neo4j' }) try { await session.executeRead(async tx => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name')

// ✅ Modern pattern: for-await iterates records one at a time as they arrive for await (const record of result) { process(record.get('name')) // process each record without buffering all into memory } // No return value needed — side effects handled inline }) } finally { await session.close() }

// ✅ Also correct: .subscribe() for callback-style streaming (older pattern) await session.executeRead(async tx => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name') await new Promise((resolve, reject) => { result.subscribe({ onNext(record) { process(record.get('name')) }, onCompleted() { resolve() }, onError(err) { reject(err) } }) }) })

// ❌ Don't use .subscribe() without awaiting tx.run() first — // subscribing to the unresolved Result thenable is undefined behaviour: await session.executeRead(async tx => { const result = tx.run('MATCH ...') // NOT awaited await new Promise((resolve, reject) => { result.subscribe({ ... }) // called on thenable, not resolved Result }) })


### Connection Pool Tuning

const driver = neo4j.driver(URI, auth, { maxConnectionPoolSize: 50, // default: 100 connectionAcquisitionTimeout: 30000, // ms to wait for a free connection; default: 60000 maxConnectionLifetime: 3600000, // ms; recycle old connections connectionTimeout: 15000, // ms to establish a new connection })


---

## 13. Causal Consistency & Bookmarks

**Within a single session**, queries are automatically causally chained. **Across sessions**, use `executeQuery` (auto-managed via `executeQueryBookmarkManager`) or pass bookmarks explicitly:

// Sessions A and B run concurrently; session C must see both writes const sessionA = driver.session({ database: 'neo4j' }) try { await sessionA.executeWrite(async tx => tx.run("MERGE (p:Person {name: 'Alice'})") ) } finally { await sessionA.close() } const bookmarksA = sessionA.lastBookmarks()

const sessionB = driver.session({ database: 'neo4j' }) try { await sessionB.executeWrite(async tx => tx.run("MERGE (p:Person {name: 'Bob'})") ) } finally { await sessionB.close() } const bookmarksB = sessionB.lastBookmarks()

// sessionC waits until both Alice and Bob exist const sessionC = driver.session({ database: 'neo4j', bookmarks: [...bookmarksA, ...bookmarksB] // spread both bookmark arrays }) try { await sessionC.executeWrite(async tx => tx.run("MATCH (a:Person {name:'Alice'}), (b:Person {name:'Bob'}) MERGE (a)-[:KNOWS]->(b)") ) } finally { await sessionC.close() }


`executeQuery` uses a shared `BookmarkManager` automatically — usually all you need.

---

## 14. TypeScript Usage

The driver ships with full TypeScript type definitions.

import neo4j, { Driver, Session, ManagedTransaction, Record, Node, Relationship, Integer, QueryResult, } from 'neo4j-driver'

const driver: Driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD))

// Typed session const session: Session = driver.session({ database: 'neo4j' })

// Typed transaction callback const names: string[] = await session.executeRead( async (tx: ManagedTransaction): Promise<string[]> => { const result = await tx.run('MATCH (p:Person) RETURN p.name AS name') return result.records.map((r: Record) => r.get('name') as string) } )

// Typed node access const node = record.get('p') as Node<Integer> const name: string = node.properties.name const age: number = node.properties.age.toNumber()


### TypeScript with `disableLosslessIntegers`

// With disableLosslessIntegers: true, Integer fields become number import neo4j, { Node } from 'neo4j-driver'

const driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD), { disableLosslessIntegers: true })

// Node generic changes: Node<number> instead of Node<Integer> const node = record.get('p') as Node<number> const age: number = node.properties.age // already a number, no .toNumber() needed


---

## 15. Browser / WebSocket Usage

In browser environments, the driver communicates over **WebSockets**, not raw TCP. The URI scheme must reflect this:

// ❌ Wrong in a browser — bolt:// uses TCP, not supported in browsers neo4j.driver('bolt://localhost:7687', auth)

// ✅ Correct — neo4j+s:// uses WebSocket (WSS) in browser builds neo4j.driver('neo4j+s://xxx.databases.neo4j.io', auth) // For local dev without TLS: neo4j:// (uses WS) neo4j.driver('neo4j://localhost:7687', auth)


**Bundling**: the `neo4j-driver` npm package works in the browser when bundled with webpack, Vite, Rollup, etc. No separate browser-specific package is needed for modern bundlers.

**CORS**: Bolt/WebSocket connections bypass CORS (they are not HTTP). The browser connects directly to Neo4j's Bolt port (default 7687). Ensure the Neo4j server allows WebSocket connections from your origin via its connector config.

**Security**: never embed production credentials in client-side JavaScript. Use a backend API as a proxy to the database when building browser applications.

---

## 16. Repository Pattern — Recommended Structure

class PersonRepository { constructor(driver, database = 'neo4j') { this.driver = driver this.db = database }

async findByNamePrefix(prefix) { const { records } = await this.driver.executeQuery( 'MATCH (p:Person) WHERE p.name STARTS WITH $prefix RETURN p.name AS name, p.age AS age', { prefix }, { database: this.db, routing: neo4j.routing.READ } ) return records.map(r => ({ name: r.get('name'), age: r.get('age').toNumber() })) }

async create(name, age) { await this.driver.executeQuery( 'CREATE (p:Person {name: $name, age: $age})', { name, age: neo4j.int(age) }, { database: this.db } ) }

async bulkCreate(people) { // people = [{ name, age }] — plain objects await this.driver.executeQuery( UNWIND $people AS person MERGE (p:Person {name: person.name}) SET p.age = person.age, { people }, { database: this.db } ) } }


---

## 17. Error Handling

import { Neo4jError, SERVICE_UNAVAILABLE, SESSION_EXPIRED } from 'neo4j-driver'

try { await driver.executeQuery('...', {}, { database: 'neo4j' }) } catch (err) { if (err instanceof Neo4jError) { switch (err.code) { case 'Neo.ClientError.Schema.ConstraintValidationFailed': // Unique or existence constraint violation console.error('Constraint violated:', err.message) break case SERVICE_UNAVAILABLE: // 'ServiceUnavailable' console.error('Database unreachable') break case SESSION_EXPIRED: // 'SessionExpired' console.error('Session expired — open a new session') break default: if (err.retriable) { // Transient — executeQuery already retried; this is after exhaustion console.error('Transient error exhausted retries:', err.code) } else { console.error(Neo4j error [${err.code}]:, err.message) } } } }


Constraint violation codes follow the pattern `Neo.ClientError.Schema.ConstraintValidationFailed`. GQL status codes (on `err.gqlStatus`) are stable across versions and preferable to string-matching error messages.

---

## 18. Quick Reference: Common Mistakes

| Mistake | Fix |
|---------|-----|
| Template literal / string concat Cypher | Use `$param` placeholders always |
| `record.name` or `record['name']` | Use `record.get('name')` — records are not plain objects |
| `record.toObject()` then `JSON.stringify()` | Values are still driver types — convert with `toNative()` or map manually |
| `JSON.stringify(record.get('age'))` on an Integer | Call `.toNumber()` first, or use `disableLosslessIntegers` |
| `JSON.stringify` on temporal types | Call `.toString()` first — temporals serialise to `{}` silently |
| `JSON.stringify` with `useBigInt: true` | Add a replacer or use `.toString()` — BigInt breaks JSON |
| `summary.counters.nodesCreated` | Must call `.updates()` first: `summary.counters.updates().nodesCreated` |
| `summary.resultAvailableAfter` for total query time | Use `summary.resultConsumedAfter` for wall-clock query duration |
| Omitting `database` on every query | Always set `{ database: 'neo4j' }` — saves a round-trip |
| Assuming `await tx.run()` populates `result.records` | It returns a stream — use `.collect()` or `for await` to fetch records |
| Returning `result` from tx callback | Return `await result.collect()` or mapped data — not the stream object |
| `toNative(record.get('p'))` on a whole Node | `toNative()` must handle Node explicitly; or pass `record.get('p').properties` |
| `result.subscribe()` without awaiting `tx.run()` first | Always `await tx.run()` before calling `.subscribe()` |
| `.then(() => session.close())` for cleanup | Use `try/finally { await session.close() }` |
| Side effects inside `executeRead/Write` callbacks | Move them outside — the callback may be retried |
| One transaction per write in a loop | Batch with `UNWIND` |
| Using `MERGE` for guaranteed-new data | Use `CREATE` — `MERGE` costs an extra match round-trip |
| `executeWrite` for a read query | Use `executeRead` — routes to replicas |
| `neo4j://` or `bolt://` URI in browser | Use `neo4j+s://` (WSS) or `neo4j://` (WS) for browser/WebSocket |
| Embedding credentials in browser JS | Use a backend proxy — never expose DB credentials client-side |
| Creating a new `Driver` per request | Create once at startup, share across requests |
| `maxConnectionPoolSize` default (100) in serverless | Use 5–10 per function instance to avoid connection storms |
| `integer.toNumber()` without range check | Use `integer.inSafeRange(value)` first for large integers |

Related skills

Databasesdatabases

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.