Connection & Offline Behaviour
Trackkit includes lightweight primitives for:
- understanding whether the client appears online, offline or slow
- buffering events in memory (and optionally in localStorage)
- draining / clearing queues coherently across SSR + runtime
This guide explains how the connection monitor, offline store, and queues fit together.
Connection monitoring
Connection monitoring is handled by the ConnectionMonitor in connection/monitor.ts.
It maintains a simple state machine:
export type ConnectionState = 'online' | 'offline' | 'slow';How it decides the state
On construction:
- If
windowexists, it registersonline/offlineevent listeners and starts a periodic check. - Initial state is
onlineorofflinebased onnavigator.onLine.
- If
When your code calls
reportSuccess():- It records
lastSuccess = Date.now(). - If the previous state was not
online, it transitions back toonline.
- It records
When your code calls
reportFailure(err):- If
navigator.onLine === false, state becomesoffline. - Otherwise, if the error message contains
"Failed to fetch", state becomesofflineas a hint. - Otherwise, if enough time has passed since the last success (
slowThresholdms), state becomesslow.
- If
Periodically (every
checkIntervalms),checkSlow()runs:- If state is
onlineandnow - lastSuccess > slowThreshold * 2, state becomesslow.
- If state is
Defaults (from the constructor):
slowThreshold:3000mscheckInterval:30000ms
You can access the current state and subscribe:
const monitor = new ConnectionMonitor({ slowThreshold: 3000, checkInterval: 30000 });
monitor.subscribe(state => {
console.log('Connection state changed:', state);
});
monitor.getState(); // 'online' | 'offline' | 'slow'
monitor.isHealthy(); // true if 'online', false otherwiseCall destroy() when you’re done to remove event listeners and the interval.
Note: ConnectionMonitor doesn’t send or retry anything by itself; it just tracks state you can use to make decisions.
Offline buffering
Offline buffering is handled by OfflineStore in connection/offline-store.ts.
OfflineEvent format
export type OfflineEvent = {
type: EventType;
args?: any[];
url?: string;
category?: ConsentCategory;
timestamp: number;
};This mirrors the shape of queued façade events (type, args, category, timestamp) plus an optional url.
Storage abstraction
Offline storage is abstracted behind an OfflineStorage interface:
export interface OfflineStorage {
save(events: OfflineEvent[]): void | Promise<void>;
load(): OfflineEvent[] | Promise<OfflineEvent[]>;
clear(): void | Promise<void>;
}The default implementation, LocalStorageStorage, uses window.localStorage when available:
save:- loads existing events
- appends new ones
- keeps only the last 1000 (
slice(-1000)) - writes back to
localStorage
load:- parses JSON from localStorage
- returns
[]on any error
clear:- removes the key from storage
In non-browser environments (window or localStorage missing), it behaves as a no-op, returning empty arrays and doing nothing on save/clear.
OfflineStore wrapper
OfflineStore is a thin wrapper:
export class OfflineStore {
constructor(private storage: OfflineStorage = new LocalStorageStorage()) {}
async saveOffline(events: OfflineEvent[]) {
await this.storage.save(events);
}
async drainOffline(): Promise<OfflineEvent[]> {
const events = await this.storage.load();
if (events.length) await this.storage.clear();
return events;
}
}Usage pattern (simplified):
When you detect “we can’t send right now”:
- call
offlineStore.saveOffline([...events])to persist them.
- call
On the next successful initialisation (with connectivity):
- call
offlineStore.drainOffline()and feed returned events into your normal queue.
- call
The maximum retained history is 1000 events by default.
Drained offline events re-enter the normal queue and pass through the same consent and policy gates as any other event. If consent is denied at drain time, analytics events are dropped and essential events follow
allowEssentialOnDenied.
Runtime queues and SSR
Queue behaviour is implemented by:
EventQueueinqueues/runtime.ts(runtime façade queue)SSRQueueinqueues/ssr.ts(SSR queue, not shown here)QueueServiceinqueues/service.ts(unifies runtime + SSR)
EventQueue (runtime)
EventQueue is a typed queue with consent-awareness and overflow handling:
enqueue(type, args, category, pageContext?):drops events if the queue is
paused.deep-clones
argsandpageContext.assigns a unique
id(evt_<timestamp>_<counter>).on overflow (
queue.length >= maxSize):- drops the oldest events required to make space for the new one.
- logs a warning (
Queue overflow, dropping N oldest events). - calls
onOverflow(dropped)if configured.
flush():- returns all queued events.
- clears the queue.
flushEssential():- returns only events whose
category === 'essential'. - leaves non-essential events in the queue.
- returns only events whose
clear()/clearNonEssential():clear()drops everything and returns how many were dropped.clearNonEssential()drops only non-essential events and returns the count.
pause()/resume():pause()prevents new events from being enqueued (they are dropped immediately).resume()allows events to be queued again.
State helpers:
getState()→{ size, isPaused, oldestEventAge }getEvents()→ shallow copy of all queued events (for debugging)getCapacity()→maxSizegetOverflowHandler()→ the current overflow handlersize/isEmptygetters
On overflow, newest events are preserved and oldest are dropped. This is a deliberate choice (documented in code) and aligns with the QUEUE_OVERFLOW error semantics.
QueueService (runtime + SSR)
QueueService wraps both runtime and SSR queues:
export class QueueService {
private runtime: EventQueue;
private ssr: IQueue;
constructor(cfg: QueueConfig) {
this.runtime = new EventQueue(cfg);
this.ssr = new SSRQueue();
}
enqueue(...) // runtime-only enqueue
flushAll() // SSR.flush() + runtime.flush()
flushEssential() // SSR.flushEssential() + runtime.flushEssential()
clearAll() // SSR.clear() + runtime.clear()
clearNonEssential() // SSR.clearNonEssential() + runtime.clearNonEssential()
size() // runtime.size + ssr.size
capacity() // runtime.getCapacity()
getOverflowHandler() // runtime.getOverflowHandler()
}The invariants:
flushAll()drains both SSR and runtime queues.flushEssential()only drains essential events from both queues; non-essential remain queued.size()reflects total buffered events across both queues.capacity()reflects the runtime queue capacity; SSR queue is not capacity-limited in the same way.
This is what DiagnosticsService reports via:
queue: {
totalBuffered: runtimeSize + ssrSize,
ssrQueueBuffered: ssrSize,
facadeQueueBuffered: runtimeSize,
capacity: runtimeCapacity,
}Putting it together: connection, offline, and queues
The intended flow is:
Connection monitor observes
online/offline/slowstate.Queues buffer events according to consent and queue configuration.
Offline store optionally persists buffered events when network conditions are bad.
When connectivity is healthy again:
- offline events are drained,
- queues are flushed,
- events are sent via the normal dispatcher/resilience pipeline.
Your own app decides:
- when to call
reportSuccess/reportFailureon the monitor, - when to treat
state === 'slow'as “stop sending for now” vs “just show a warning”, - when to save to and drain from
OfflineStore.
Trackkit’s role is:
- provide a central queue with sensible overflow semantics,
- provide a simple connection state primitive,
- provide an offline store abstraction that’s safe in non-browser contexts.
Example: basic offline-aware setup
Here’s a sketch of how you might connect the pieces in your own app:
import { createAnalytics } from 'trackkit';
import { ConnectionMonitor } from 'trackkit/connection/monitor';
import { OfflineStore } from 'trackkit/connection/offline-store';
const monitor = new ConnectionMonitor();
const offlineStore = new OfflineStore();
const analytics = createAnalytics({
provider: 'umami',
site: '…',
// normal config…
});
// When you fail to send a batch:
async function onDispatchError(events, err) {
monitor.reportFailure(err);
if (!monitor.isHealthy()) {
await offlineStore.saveOffline(
events.map(ev => ({
type: ev.type,
args: ev.args,
category: ev.category,
url: ev.pageContext?.url,
timestamp: ev.timestamp,
}))
);
}
}
// On init / reconnect:
async function onInit() {
monitor.reportSuccess();
const offlineEvents = await offlineStore.drainOffline();
for (const ev of offlineEvents) {
analytics.track(ev.type, ...(ev.args || []));
}
}You don’t have to wire it up exactly like this, but this is the level at which these primitives are intended to be used: small building blocks, not a fully automatic offline system.