GraphQL subscriptions let you stream real-time updates from TerminusDB over Server-Sent Events (SSE). When a document is inserted, updated, or deleted, the server pushes an event to every active subscriber matching that change. This page is the complete reference for the subscription protocol, event formats, error handling, and client integration.
How subscriptions work
TerminusDB implements GraphQL subscriptions using the graphql-sse protocol in distinct connections mode. Each subscription request opens a long-lived HTTP connection (with keepalive) that the server keeps open and writes SSE events to as data changes.
The flow is straightforward:
- Your client sends a
POSTrequest withAccept: text/event-streamand a GraphQL subscription query in the body. - The server validates the query, resolves the target data product, and registers the subscription.
- The server sends a
connectedevent (a TerminusDB extension — see below). - When a commit matching the subscription occurs, the server sends a
nextevent with the result payload. - When the subscription ends — either because the server closes the stream or a validation error occurs — the server sends a
completeevent.
With Websockets, you often get into issues where authentication needs to be passed within messages, which is often messy in practice, including firewall piercing which is often less than trivial and requires special configuration.
SSE is often easier to work with than websockets in these scenarios, even if you may need to ensure that the path is not buffering to get the best performance.
Endpoint URL
Subscriptions use the same GraphQL endpoint as regular queries, but with a different Accept header:
SERVERNAME/api/graphql/ORG/DATAPRODUCTFor a local instance with a data product named admin/people:
http://localhost:6363/api/graphql/admin/peopleThe difference is in the request headers. A subscription request looks like this:
POST /api/graphql/admin/people HTTP/1.1
Host: localhost:6363
Content-Type: application/json
Accept: text/event-stream
Authorization: Basic YWRtaW46cm9vdA==
{"query": "subscription { Person_added { _id name } }"}Authentication
Subscriptions use the same authentication as regular GraphQL requests. See Connecting to GraphQL for details on Basic Auth and DFRNT Cloud token authentication.
Subscription queries
A subscription query starts with the subscription keyword followed by a selection set. TerminusDB generates subscription fields from your schema — one field per document type per change type.
Change types
Each document class generates three subscription fields:
<Type>_added— fires when a new document of that type is inserted<Type>_updated— fires when an existing document of that type is replaced<Type>_deleted— fires when a document of that type is deleted
For example, with a Person class in your schema:
subscription { Person_added { _id name } }This subscription streams an event every time a new Person document is inserted. The selection set (_id name) determines which fields are included in each event payload.
Filtering
Subscription fields accept a filter argument, using the same filter types as regular GraphQL queries:
subscription {
Person_added(filter: { name: { eq: "Alice" } }) {
_id
name
}
}Only inserts where name equals "Alice" will produce events. See Filter with GraphQL for the full filter syntax.
Commit metadata
Each event includes a _CommitMetadata object when explicitly requested in the selection set:
subscription {
Person_added {
_id
name
_CommitMetadata {
_id
_timestamp
_datetime
_change_type
}
}
}The _CommitMetadata field provides:
_id— the commit identifier_timestamp— the commit timestamp as a Unix epoch value_datetime— the commit timestamp as an ISO 8601 string_change_type— the type of change (added,updated, ordeleted)
Subclass matching
Subscriptions respect inheritance. If Dog inherits from Animal, subscribing to Animal_added will receive events for both Animal and Dog documents. This applies to per-document subscriptions and _ChangeSet alike.
For _ChangeSet subscriptions, you can control this behavior with the include_children argument. It defaults to true, meaning subclass documents are included. Set it to false at the top level to exclude subclass documents from all fields:
subscription {
_ChangeSet(include_children: false) {
Animal_added { _id name }
}
}With include_children: false, only direct Animal documents appear in Animal_added — Dog instances are excluded. You can also override the top-level setting on individual fields:
subscription {
_ChangeSet(include_children: false) {
Animal_added(include_children: true) { _id name }
Dog_added { _id name }
}
}Here Animal_added includes subclass documents (per-field override), while Dog_added respects the top-level false.
_ChangeSet subscriptions
Per-document subscriptions (Person_added, Person_deleted, etc.) fire once per changed document. When a single commit touches many documents, that means many events. _ChangeSet solves this by firing once per commit with all changes batched into a single payload.
Query syntax
subscription {
_ChangeSet {
Person_added { _id name }
Person_changed { _id name }
Person_deleted { _id name }
_CommitMetadata { _id _timestamp _datetime _change_type }
}
}Each field under _ChangeSet follows the same {Class}_{operation} naming as per-document subscriptions, but returns a list of documents instead of a single document. The selection set on each field determines which fields are returned per document — same resolution mechanism, same security guarantees.
Event shape
{
"data": {
"_ChangeSet": {
"Person_added": [
{"_id": "Person/alice", "name": "Alice"},
{"_id": "Person/bob", "name": "Bob"}
],
"Person_changed": [],
"Person_deleted": [
{"_id": "Person/charlie", "name": "Charlie"}
],
"_CommitMetadata": {
"_id": "commit123",
"_timestamp": 1722528000.0,
"_datetime": "2024-08-01T16:00:00Z",
"_change_type": "commit"
}
}
}
}The _change_type for _ChangeSet events is "commit" (not added/changed/deleted) because the event represents the entire commit, not an individual document change.
Deleted document resolution
Deleted documents are resolved from the pre-commit layer — the state of the database before the commit was applied. This means you get the full document data (all fields that existed before deletion), not just the _id. The server obtains the parent layer internally; no extra configuration is needed.
On-demand querying
_ChangeSet is also available on the Query root, so you can request the changes of the current commit on demand:
query {
_ChangeSet {
Person_added { _id name }
}
}This returns the changes from the current transaction context. Note that deleted documents may not resolve fully on the Query root since they no longer exist in the current instance layer — full deleted resolution works only in the subscription context where the pre-commit layer is available.
Coexistence with per-document subscriptions
_ChangeSet and per-document subscriptions are independent. You can subscribe to both on the same connection (or separate connections) and receive events from both. _ChangeSet fires once with the batch; per-document fields fire once per document. Neither interferes with the other.
When to use _ChangeSet vs per-document
| Use case | Recommended subscription |
|---|---|
| React to individual document changes in real time | Per-document (Person_added) |
| Batch processing — process all changes from a commit at once | _ChangeSet |
| Audit log — record every change with commit metadata | _ChangeSet with _CommitMetadata |
| Selective — only care about one type | Per-document with filter |
| Multiple types in one event | _ChangeSet |
SSE event types
TerminusDB sends three types of SSE events (plus one non-standard extension). Each event follows the standard SSE format with event: and data: fields separated by a blank line.
connected (TerminusDB extension)
After a subscription is accepted, the server sends a connected event before any next events. This is not part of the graphql-sse protocol — it is a TerminusDB extension.
event: connected
data: nullThe purpose is to signal subscription readiness. Without it, clients cannot know when it is safe to trigger data operations that should produce subscription events, creating a race condition between subscribing and the first mutation.
Strict SSE clients ignore unknown event types per the SSE specification, so this event is safe to ignore if your client does not need it.
next
A next event carries a subscription result — the data payload for a matching change. The data field contains a JSON object with the selected fields:
event: next
data: {"Person_added":{"_id":"Person/alice","name":"Alice"}}A _ChangeSet event looks like:
event: next
data: {"_ChangeSet":{"Person_added":[{"_id":"Person/alice","name":"Alice"}],"_CommitMetadata":{"_id":"commit123","_change_type":"commit"}}}Multiple next events can be sent over the lifetime of a single subscription, one for each matching commit.
complete
A complete event signals that the subscription is done and no more events will follow. The data field is empty:
event: complete
data:After sending complete, the server closes the SSE stream. This event is sent when:
- A validation error occurs (the query fails to parse)
- A finite operation (query or mutation) is executed over SSE
- The server initiates a stream close during cleanup
Error events
Validation errors are reported as next events containing a GraphQL errors object, followed by a complete event. The HTTP status code is always 200 with text/event-stream content type — errors are never sent as HTTP 4xx responses for SSE connections.
event: next
data: {"errors":[{"message":"Failed to parse subscription query"}]}
event: complete
data:This follows the graphql-sse protocol, which requires validation errors to be reported through an accepted SSE connection rather than as HTTP error responses.
Finite operations over SSE
TerminusDB also supports executing regular queries and mutations over an SSE connection. If you send a query or mutation (rather than a subscription) with Accept: text/event-stream, the server executes it, returns the result as a single next event, and then sends complete:
event: next
data: {"data":{"Person":[{"_id":"Person/alice","name":"Alice"}]}}
event: complete
data:This is useful when you want a uniform SSE-based interface for all GraphQL operations — subscriptions and finite operations alike. The server detects the operation type by checking whether the query starts with subscription. Queries and mutations are executed with the same authentication and access control as regular GraphQL requests.
NDJSON mode
TerminusDB also supports NDJSON (newline-delimited JSON) as an alternative to SSE. To use NDJSON, send Accept: application/x-ndjson instead of text/event-stream:
POST /api/graphql/admin/people HTTP/1.1
Host: localhost:6363
Content-Type: application/json
Accept: application/x-ndjson
Authorization: Basic YWRtaW46cm9vdA==
{"query": "subscription { Person_added { _id name } }"}In NDJSON mode, events are sent as plain JSON lines without event: prefixes:
connected→nullnext→{"Person_added":{"_id":"Person/alice","name":"Alice"}}or{"_ChangeSet":{"Person_added":[...]}}complete→ (empty line)
System database subscriptions
You can subscribe to changes in the system database to monitor database lifecycle events. The system database endpoint is /api/graphql/_system:
POST /api/graphql/_system HTTP/1.1
Host: localhost:6363
Content-Type: application/json
Accept: text/event-stream
Authorization: Basic YWRtaW46cm9vdA==
{"query": "subscription { UserDatabase_added { _id name } }"}This streams an event whenever a new database is created. System database subscriptions require admin authentication.
CORS
SSE endpoints support standard CORS headers. The server reflects the Origin request header in Access-Control-Allow-Origin and responds to OPTIONS preflight requests with 204 No Content. See Browser CORS for details.
Client integration
Using fetch and EventSource
The most direct way to consume SSE subscriptions in JavaScript is with the fetch API and a streaming body reader:
const response = await fetch('http://localhost:6363/api/graphql/admin/people', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': 'Basic ' + btoa('admin:root'),
},
body: JSON.stringify({
query: 'subscription { Person_added { _id name } }',
}),
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Split on double newline to get SSE event blocks
const blocks = buffer.split('\n\n')
buffer = blocks.pop()
for (const block of blocks) {
if (!block.trim()) continue
const event = parseSSEBlock(block)
if (event) console.log(event)
}
}
function parseSSEBlock(block) {
let eventType = 'message'
let dataLine = null
for (const line of block.split('\n')) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim()
else if (line.startsWith('data: ')) dataLine = line.slice(6)
else if (line.startsWith('data:')) dataLine = line.slice(5)
}
if (dataLine === null) return null
if (dataLine.trim() === '') return { _eventType: eventType, data: null }
try {
const parsed = JSON.parse(dataLine)
if (typeof parsed === 'object' && parsed !== null) parsed._eventType = eventType
return { _eventType: eventType, data: parsed }
} catch { return null }
}Using the graphql-sse client
The graphql-sse npm package provides a ready-made client for the graphql-sse protocol. Since TerminusDB sends a non-standard connected event, configure the client to ignore unknown event types (which is the default behavior per the SSE specification):
import { createClient } from 'graphql-sse'
const client = createClient({
url: 'http://localhost:6363/api/graphql/admin/people',
headers: {
Authorization: 'Basic ' + btoa('admin:root'),
},
})
const unsubscribe = client.subscribe(
{ query: 'subscription { Person_added { _id name } }' },
{
next: (data) => console.log('Event:', data),
error: (err) => console.error('Error:', err),
complete: () => console.log('Subscription closed'),
}
)Using Apollo Client
Apollo Client supports subscriptions via WebSocket by default. To use SSE instead, you can use the graphql-sse transport with a custom link. See Connect with Apollo Client for setup instructions.
Protocol reference
Request format
| Field | Value |
|---|---|
| Method | POST |
| URL | /api/graphql/ORG/DATAPRODUCT |
Content-Type | application/json |
Accept | text/event-stream or application/x-ndjson |
Authorization | Basic Auth or DFRNT Cloud token |
| Body | {"query": "subscription { ... }"} |
Response headers
| Header | Value |
|---|---|
Content-Type | text/event-stream (SSE) or application/x-ndjson (NDJSON) |
Cache-Control | no-cache |
Connection | keep-alive |
X-Accel-Buffering | no |
Access-Control-Allow-Origin | Reflected from Origin request header |
SSE event format
Each event is a block of lines separated by a blank line (\n\n). Within a block:
| Line | Description |
|---|---|
event: <type> | Event type: connected, next, or complete |
data: <json> | JSON payload (empty for complete) |
NDJSON event format
Each event is a single JSON line followed by a blank line:
| Event | Line |
|---|---|
connected | null |
next | {"<Type>_<change>": {...}} |
complete | (empty line) |
Versions
- v12.1 onwards support GraphQL subscriptions with SSE (Server-Sent Events)
See also
- GraphQL Basics — write your first queries with worked examples
- GraphQL Mutations — insert, replace, and delete documents via GraphQL
- Connecting to GraphQL — endpoint URL and authentication setup
- GraphQL Query Reference — full reference for types, arguments, and fields
- Filter with GraphQL — filter syntax for queries and subscriptions
- Browser CORS — CORS configuration for browser-based clients