A production analytics call should not make a checkout fail, and a temporary network problem should not silently erase the event. The reliable pattern separates the business transaction from delivery, preserves an application-level event ID, and treats retries as normal.
GraphJSON accepts events over HTTP. The application still owns the path from “the action happened” to “the event received a successful response.”
For large imports, sustained producers, or historical replay, use the capacity planning, checkpoint, throttling, and reconciliation workflow in High-volume ingestion and backfills.
Start with explicit delivery requirements#
Not every event deserves the same machinery.
| Event | Acceptable behavior |
|---|---|
| Debug breadcrumb | Best effort may be enough |
| Feature usage | Retry briefly; occasional loss may be tolerable |
| Subscription change | Queue, retry, deduplicate, and reconcile |
| Billable usage | Keep the transactional system as the source of truth and reconcile |
Analytics should not become the canonical ledger for money, permissions, inventory, or another business-critical state. It can analyze those facts after the source system records them.
Use a stable event envelope#
Give every important event an identifier before the first delivery attempt:
{
"event": "invoice_paid",
"event_id": "evt_01J2Y9DA7W",
"occurred_at": "2026-07-26T16:00:00Z",
"account_id": "acct_7",
"invoice_id": "in_91",
"amount": 4900,
"currency": "usd",
"schema_version": 1
}
Send occurred_at as the GraphJSON timestamp in whole Unix seconds. Keep event_id inside the JSON payload so duplicates can be found later.
Generate the ID at the source of the event, not inside the delivery worker. A retry must keep the same ID.
Prefer an outbox or durable queue#
The strongest pattern writes the application change and an outgoing event in the same database transaction:
request
├─ update application state
└─ insert outbox row
↓
background worker
↓
GraphJSON
If your stack cannot use a transactional outbox, enqueue after the business transaction commits. Decide what happens when the enqueue itself fails and make that failure observable.
For less critical events, an in-process buffer can be reasonable, but process exits and serverless freezes can drop buffered data. Do not describe an in-memory buffer as durable.
Batch without losing control#
POST /api/bulk-log accepts up to 50 events. A batch amortizes connections and leaves more room under the 40-ingestion-requests-per-second workspace guardrail.
Build batches by:
- destination collection
- a maximum of 50 events
- a maximum wait, such as one second
- a conservative total request size
Each serialized event must be no more than 10,000 characters. The jsons and timestamps arrays must be non-empty and have equal lengths.
Do not create one unbounded batch or an unbounded Promise.all. Both make a partial outage harder to recover from.
Apply a bounded retry policy#
Retry:
- network failures
- timeouts
429500
Do not retry an unchanged 400; fix or quarantine the payload.
A practical schedule uses exponential backoff with jitter:
attempt 1: 250–500 ms
attempt 2: 500–1,000 ms
attempt 3: 1–2 s
attempt 4: 2–4 s
Set:
- a timeout for each request
- a maximum attempt count
- an overall delivery deadline
- a concurrency limit per worker
Jitter prevents many workers from retrying at exactly the same moment.
Reference worker#
This example accepts a batch that has already been read from a durable queue:
const ENDPOINT = "https://api.graphjson.com/api/bulk-log";
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function retryDelay(attempt) {
const ceiling = Math.min(4000, 250 * 2 ** attempt);
return Math.floor(ceiling / 2 + Math.random() * (ceiling / 2));
}
export async function deliverBatch(events, { maxAttempts = 5 } = {}) {
const body = {
api_key: process.env.GRAPHJSON_API_KEY,
collection: "product_events",
timestamps: events.map((event) =>
Math.floor(new Date(event.occurred_at).getTime() / 1000)
),
jsons: events.map((event) => JSON.stringify(event))
};
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000)
});
if (response.ok) return;
const message = await response.text();
if (response.status === 400) {
throw new PermanentDeliveryError(message);
}
if (response.status !== 429 && response.status < 500) {
throw new PermanentDeliveryError(
`Unexpected ${response.status}: ${message}`
);
}
} catch (error) {
if (error instanceof PermanentDeliveryError) throw error;
if (attempt === maxAttempts - 1) throw error;
}
await wait(retryDelay(attempt));
}
}
class PermanentDeliveryError extends Error {}
The queue handler should acknowledge its job only after deliverBatch returns. Exhausted and permanent failures belong in a dead-letter queue with the API key and sensitive payload fields redacted from logs.
Understand delivery semantics#
GraphJSON logging endpoints do not expose an idempotency-key mechanism. If a client loses the response, it may not know whether the event was accepted.
Design for at least once:
- keep a stable
event_id - retry uncertain deliveries
- accept that a duplicate can arrive
- deduplicate in SQL when a metric requires it
For example:
WITH
JSONExtractString(json, 'event_id') AS event_id,
JSONExtractString(json, 'event') AS event_name
SELECT count()
FROM
(
SELECT event_id, any(event_name)
FROM product_events
WHERE event_id != ''
GROUP BY event_id
)
This assumes every retry with one event_id has the same immutable payload. The deduplication guide covers latest-value and tie-breaking patterns for mutable business events.
Reconcile instead of trusting the happy path#
For important streams, compare GraphJSON with the source system:
- source events created per hour
- queue jobs completed, retried, and dead-lettered
- GraphJSON events by
event_idand source period - earliest and latest event time
- duplicate and missing IDs
Reconciliation should use event time for the business period and ingestion time for pipeline delay. A backfill arriving today may correctly belong to last month.
Start a new integration with a small known sample. Verify counts, timestamps, field types, and identifiers before increasing traffic.
Keep analytics failures away from users#
For an ordinary product event:
- complete the user-facing transaction
- enqueue the event
- let a worker deliver it
- alert operators if the queue or dead-letter count grows
If the event cannot be enqueued, log a redacted operational error and follow the failure policy for that event class. Do not catch every failure silently; “analytics never breaks the request” should not mean “analytics can disappear unnoticed.”
Delivery checklist#
- assign a stable source
event_id - preserve the original event timestamp
- separate business success from network delivery
- use a durable queue for important events
- batch at no more than 50 events
- bound concurrency, timeouts, retries, and total delivery time
- retry network failures,
429, and500 - quarantine unchanged
400responses - dead-letter exhausted events
- deduplicate when the metric requires it
- monitor queue age and failure counts
- reconcile a sample period against the source
For endpoint details, see bulk logging and errors and limits.