+1 (415) 649-9454

Neo4j Without a Driver: The Query API for Serverless and Edge Apps

Every Neo4j tutorial you have read, including most of ours, starts the same way: install a driver, open a Bolt connection, hold onto it for the life of the process. That model is excellent on a long-lived application server and awkward everywhere else. A Cloudflare Worker cannot open a raw TCP socket to port 7687. A Lambda that runs for 400ms and then freezes does not benefit from a connection pool it will never reuse. A data scientist poking at a graph from a notebook on a locked-down corporate network often has 443 and nothing else.

For those cases Neo4j ships the Query API — a versioned HTTP/JSON endpoint that executes Cypher, returns typed results, and needs nothing but fetch. This tutorial covers it end to end: the request shape, the two result formats, parameters, explicit transactions, auth (including bearer tokens and impersonation), error handling and retries, and the performance ceiling you should know about before you make it your default access path.

When the Query API is the right tool

Use it when:

  • You run on an edge or serverless runtime with no raw TCP (Cloudflare Workers, Deno Deploy, Vercel Edge, admin tooling behind a proxy).
  • Your function invocations are short and stateless, so connection pooling buys you nothing.
  • Only HTTPS/443 egress is allowed out of the network.
  • You are in a language with no official driver — or a shell script, or curl in a health check.

Stay on Bolt when you have a long-lived process, chatty workloads, large result sets, or you need the driver's cluster routing, causal-consistency bookmarks and retry logic for free. Bolt is a binary protocol over a persistent connection; it is meaningfully faster per query and cheaper per row. Our post on causal consistency and bookmarks is all Bolt territory, and that does not change.

Prerequisites

  • Neo4j 5.23+ self-managed (the Query API is served by the HTTP connector) or any current Aura instance, which exposes it at https://<dbid>.databases.neo4j.io/db/neo4j/query/v2.
  • curl, and Node 18+ (or any runtime with fetch) for the code below.

If you are running the Docker setup from Getting Started with Neo4j in Docker, make sure the HTTP port is published as well as Bolt:

docker run -d --name neo4j \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/change-me-please \
  neo4j:2026.01-enterprise

Confirm the endpoint answers:

curl -s -u neo4j:change-me-please \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{"statement":"RETURN 1 AS n"}' \
  http://localhost:7474/db/neo4j/query/v2

Note the database name in the path. The endpoint is /db/{database}/query/v2 — one database per request, selected by URL rather than by session config. Use /db/system/query/v2 for administrative Cypher such as SHOW DATABASES.

The request and response shape

A request is a JSON object with statement, optional parameters, and a few flags:

{
  "statement": "MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) RETURN m.title AS title, m.released AS released ORDER BY released",
  "parameters": { "name": "Keanu Reeves" },
  "includeCounters": true,
  "accessMode": "READ",
  "bookmarks": []
}

The default response is a compact, column-oriented shape:

{
  "data": {
    "fields": ["title", "released"],
    "values": [["The Matrix", 1999], ["The Matrix Reloaded", 2003]]
  },
  "bookmarks": ["FB:kcwQ..."],
  "counters": { "containsUpdates": false, "nodesCreated": 0 }
}

fields plus values — not a list of objects per row. That is deliberate: it strips the repeated key names that dominate JSON payload size on wide result sets. Zip it yourself if your code wants objects:

const rows = data.values.map(v =>
  Object.fromEntries(data.fields.map((f, i) => [f, v[i]]))
);

Typed results, when you need them

Plain JSON cannot tell a 64-bit integer from a float, or a Point from an ordinary map. Ask for the typed format when that matters:

curl -s -u neo4j:change-me-please \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/vnd.neo4j.query' \
  -d '{"statement":"RETURN 9007199254740993 AS big, point({latitude:55.6,longitude:12.6}) AS loc, datetime() AS now"}' \
  http://localhost:7474/db/neo4j/query/v2

Values then come back tagged, roughly like this:

{"$type": "Integer", "_value": "9007199254740993"}
{"$type": "Point",   "_value": {"srid": 4326, "coordinates": [12.6, 55.6]}}
{"$type": "OffsetDateTime", "_value": "2026-02-11T09:14:02.113+00:00"}

Integers arrive as strings, so nothing is silently mangled by JavaScript's Number. Set the same media type on Content-Type when you need to send typed parameters — a Duration, a Point, a temporal value that the plain format would flatten into a string.

Rule of thumb: plain JSON for dashboards and app queries over safe numeric ranges; the typed format for money, identifiers above 2^53, spatial and temporal data, and anything you round-trip back into the graph.

Parameters are not optional

Everything we wrote in Text-to-Cypher with Guardrails about never concatenating user input into a query applies twice as hard over HTTP, where the endpoint is one misconfigured CORS rule away from being reachable by strangers. Always use parameters. Parameterised queries are also the only way to get plan-cache reuse; string-built Cypher recompiles on every call and shows up as a plan-cache miss storm in the metrics and query logs described in Neo4j Observability.

A minimal edge-runtime client

No dependencies; works in Workers, Deno, Bun, Lambda and Node:

const AUTH = "Basic " + btoa(`${env.NEO4J_USER}:${env.NEO4J_PASSWORD}`);
const ENDPOINT = `${env.NEO4J_HTTP_URL}/db/${env.NEO4J_DATABASE}/query/v2`;
const H = {
  "Content-Type": "application/json",
  Accept: "application/json",
  Authorization: AUTH,
};

export async function query(statement, parameters = {}, opts = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: H,
    body: JSON.stringify({
      statement,
      parameters,
      accessMode: opts.write ? "WRITE" : "READ",
      bookmarks: opts.bookmarks ?? [],
      includeCounters: !!opts.write,
    }),
  });

  const payload = await res.json();

  if (!res.ok) {
    const err = payload.errors?.[0] ?? {};
    const e = new Error(`${err.code ?? res.status}: ${err.message ?? "query failed"}`);
    e.code = err.code;
    e.status = res.status;
    throw e;
  }

  const { fields, values } = payload.data;
  return {
    rows: values.map(v => Object.fromEntries(fields.map((f, i) => [f, v[i]]))),
    bookmarks: payload.bookmarks,
    counters: payload.counters,
  };
}

Two details in there deserve a callout.

accessMode: "READ" is how a cluster learns it may serve your query from a secondary. Get it wrong and every read lands on the primary — the single most common cause of the "the Query API is slow" reports we get asked to investigate.

bookmarks are how you keep read-your-own-writes across stateless calls. A driver does this invisibly inside a session; over HTTP you do it by hand. Take the bookmarks array from a write response, carry it somewhere (a cookie, client state, a header passed onward), and send it with the next read:

const w = await query(
  "MERGE (o:Order {id:$id}) SET o.status = 'paid' RETURN o.id AS id",
  { id: orderId },
  { write: true }
);

const r = await query(
  "MATCH (o:Order {id:$id}) RETURN o.status AS status",
  { id: orderId },
  { bookmarks: w.bookmarks }
);

Skip that and a read routed to a lagging secondary can legitimately return the pre-write state. It is the same bookmark mechanism as Bolt, just manual.

Explicit transactions over HTTP

One POST to /query/v2 is one auto-committed transaction. When you need several round trips inside one transaction, begin one explicitly:

# 1. Begin -> returns a transaction id and an expiry
curl -s -u neo4j:pw -X POST \
  -H 'Content-Type: application/json' \
  http://localhost:7474/db/neo4j/query/v2/tx
# => {"transaction":{"id":"abc123","expires":"2026-02-11T09:20:00Z"}}

# 2. Run statements inside it
curl -s -u neo4j:pw -X POST \
  -H 'Content-Type: application/json' \
  -d '{"statement":"CREATE (:Audit {at: datetime()})"}' \
  http://localhost:7474/db/neo4j/query/v2/tx/abc123

# 3. Commit (or DELETE the same URL to roll back)
curl -s -u neo4j:pw -X POST \
  http://localhost:7474/db/neo4j/query/v2/tx/abc123/commit

Transactions have a server-side idle timeout, and an abandoned one holds its locks until it expires. Wrap the lifecycle so every failure path either commits or rolls back:

async function withTx(fn) {
  const begin = await fetch(`${ENDPOINT}/tx`, { method: "POST", headers: H });
  const { transaction } = await begin.json();
  const base = `${ENDPOINT}/tx/${transaction.id}`;
  try {
    const out = await fn(stmt =>
      fetch(base, { method: "POST", headers: H, body: JSON.stringify(stmt) })
        .then(r => r.json())
    );
    await fetch(`${base}/commit`, { method: "POST", headers: H });
    return out;
  } catch (e) {
    await fetch(base, { method: "DELETE", headers: H }).catch(() => {});
    throw e;
  }
}

One caution: an explicit transaction is pinned to a single cluster member for its lifetime, so a load balancer in front of Neo4j must use sticky sessions or route on the transaction id. If an edge function needs multi-statement atomicity, it is usually simpler to push the whole unit of work into one Cypher statement — CALL { ... } IN TRANSACTIONS for batches, as in Bulk Writes Without the Deadlocks — than to orchestrate an HTTP transaction across invocations.

Authentication and multi-tenancy

Three options, in increasing order of how much we like them:

  1. Basic auth with a service user. Fine for a prototype, and it means every request carries a long-lived credential.
  2. A bearer token from your identity provider: Authorization: Bearer <jwt>. Configure Neo4j for OIDC as in Hardening Neo4j for Production, map claims to roles, and the database enforces authorisation instead of your API layer.
  3. A service account plus impersonation, when the function must act as an end user:
{
  "statement": "MATCH (n:Doc) RETURN count(n) AS n",
  "impersonatedUser": "analyst_42"
}

The query then runs with analyst_42's roles and fine-grained privileges, so the RBAC model from Multi-Tenant Neo4j governs what comes back. That is the pattern we recommend for GraphRAG retrieval, where document permissions have to survive the trip through the retriever.

Never ship a Neo4j credential to a browser. If a front end needs graph data, put the Query API call in an edge function or gateway and let the browser talk to that.

Errors, retries and idempotency

Failures arrive as an HTTP status plus a Neo4j error payload:

{ "errors": [{ "code": "Neo.ClientError.Statement.SyntaxError", "message": "Invalid input ..." }] }

Branch on code, never on the message text:

CodeRetry?Notes
Neo.ClientError.Security.*NoFix credentials or roles
Neo.ClientError.Statement.*NoYour Cypher or parameters are wrong
Neo.TransientError.Transaction.DeadlockDetectedYesBack off with jitter, retry
Neo.TransientError.* (leader switch, term changed)YesNormal during rolling upgrades
HTTP 503 / connection resetYesMember restarting

The drivers retry transient errors for you; over HTTP you write that loop. Keep it short — two or three attempts with exponential backoff and jitter — and make writes idempotent (MERGE on a natural key, or a client-supplied request id) so a retry after an ambiguous timeout cannot double-apply.

Performance: measure before you standardise on it

Rough orders of magnitude, not benchmarks: against a local instance, RETURN 1 over Bolt costs well under a millisecond, while the same query over HTTP costs a few milliseconds, and the gap widens whenever a TLS handshake is involved. On large result sets, serialisation dominates: the fields/values layout is far leaner than row-of-objects JSON, but Bolt's packstream still moves rows in a fraction of the bytes.

Practical guidance:

  • Aggregate server-side. Return the twelve numbers a dashboard needs, not ten thousand rows for the client to reduce.
  • Never return whole nodes when a projection will do; RETURN n serialises every property.
  • Paginate with SKIP/LIMIT, or better, keyset pagination on an indexed property.
  • Reuse HTTP connections where the runtime allows it (keep-alive, or an undici agent in Node) — the handshake is often the largest single cost.
  • Set accessMode: "READ" on reads. Again.
  • Watch it with the tooling you already have. Query API traffic appears in the query log like any other client, so add a dashboard panel split by client to see the protocol mix.

If one code path turns out to be chatty or row-heavy, move that path to Bolt and keep the Query API for the edge. Mixed access is completely normal; nothing in the database cares which protocol a query arrived on.

Bonus: a two-minute health check

A pleasant side effect is that the Query API makes a better liveness probe than a TCP check, because it exercises the actual Cypher path:

#!/usr/bin/env bash
set -euo pipefail
resp=$(curl -fsS --max-time 5 -u "$NEO4J_USER:$NEO4J_PASSWORD" \
  -H 'Content-Type: application/json' \
  -d '{"statement":"RETURN 1 AS ok","accessMode":"READ"}' \
  "$NEO4J_HTTP_URL/db/$NEO4J_DATABASE/query/v2")
echo "$resp" | grep -q '"values":\[\[1\]\]' || { echo "unhealthy: $resp"; exit 1; }
echo ok

Drop that into a cron job or a Kubernetes readiness probe alongside the checks in our Neo4j Health Check Checklist.

Checklist before this goes live

  • Every statement parameterised; no string concatenation anywhere
  • accessMode set correctly on every call
  • Bookmarks threaded through read-after-write flows
  • Typed format used wherever 64-bit integers, temporal, spatial or duration values appear
  • Bearer tokens or impersonation instead of one shared basic-auth superuser
  • Retry policy keyed on error code; writes idempotent
  • Explicit transactions either avoided or fully wrapped with commit/rollback
  • HTTPS enforced, CORS locked to known origins, endpoint never exposed directly to browsers
  • Query API traffic visible in metrics and query logs

Getting graph data into an edge runtime, keeping it consistent, and keeping it authorised is one of the more common architecture questions we are asked. If you would like a senior Neo4j engineer to review your access layer — or to design it before you build it — get in touch.