Skip to main content
Version: 1.0

WebSocket Sync

SyVault uses a WebSocket connection for real-time synchronization between clients. When a record is created, updated, or deleted on one device, all other connected devices receive the change within seconds.

Connection

WS /api/sync/ws

Connect with the access token as a query parameter or in the Sec-WebSocket-Protocol header:

# Using wscat for testing
wscat -c "wss://vault.example.com/api/sync/ws?token=eyJhbGciOiJFZERTQSIs..."

In JavaScript:

const ws = new WebSocket(
`wss://vault.example.com/api/sync/ws?token=${accessToken}`
);

ws.onopen = () => {
console.log("Sync connection established");
};

Protocol

Initial Sync

After connecting, the client sends a sync_request message with the timestamp of its last successful sync. The server responds with all changes since that timestamp.

Client sends:

{
"type": "sync_request",
"last_sync": "2026-04-05T14:00:00Z"
}

Server responds:

{
"type": "sync_response",
"changes": [
{
"id": "record-uuid-1",
"vault_id": "vault-uuid-1",
"action": "updated",
"encrypted_data": "base64-encrypted-payload",
"updated_at": "2026-04-05T14:22:00Z",
"server_timestamp": "2026-04-05T14:22:01Z"
},
{
"id": "record-uuid-5",
"vault_id": "vault-uuid-1",
"action": "created",
"encrypted_data": "base64-encrypted-payload",
"updated_at": "2026-04-05T15:10:00Z",
"server_timestamp": "2026-04-05T15:10:01Z"
}
],
"deleted": [
{
"id": "record-uuid-3",
"vault_id": "vault-uuid-1",
"deleted_at": "2026-04-05T16:00:00Z"
}
],
"server_time": "2026-04-06T12:00:00Z"
}

The client should store server_time and use it as last_sync in the next sync request.

Push Notifications

After the initial sync, the server pushes change notifications in real time whenever another device modifies data:

{
"type": "push",
"action": "record_updated",
"record_id": "record-uuid-1",
"vault_id": "vault-uuid-1",
"updated_at": "2026-04-06T12:05:00Z"
}

Push notification types:

ActionDescription
record_createdA new record was added
record_updatedAn existing record was modified
record_deletedA record was removed
vault_createdA new vault was created
vault_deletedA vault was removed
vault_updatedVault metadata changed (name, travel-safe flag)
folder_updatedFolder membership or metadata changed
logoutSession was revoked (force-logout by admin)

When the client receives a push notification, it should fetch the full record from the REST API to get the updated encrypted data:

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);

if (msg.type === "push" && msg.action === "record_updated") {
// Fetch the updated record
fetch(`/api/vaults/${msg.vault_id}/records/${msg.record_id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then((res) => res.json())
.then((record) => {
// Decrypt and update local cache
updateLocalRecord(record);
});
}

if (msg.type === "push" && msg.action === "logout") {
// Server revoked our session
ws.close();
redirectToLogin();
}
};

Conflict Resolution

SyVault uses a last-write-wins strategy based on the server-assigned timestamp. When two clients modify the same record concurrently:

  1. Both clients send their updates via the REST API.
  2. The server accepts both writes and assigns each a server_timestamp.
  3. The server timestamp with the later value wins.
  4. All clients receive a push notification and fetch the winning version.

The server does not merge field-level changes. The entire encrypted_data blob from the last writer replaces the previous version. Clients should implement optimistic locking in the UI (warn the user if the record changed since they started editing) to minimize conflicts.

Heartbeat

The server sends a ping frame every 30 seconds. The client must respond with a pong frame. If the server does not receive a pong within 10 seconds, the connection is terminated.

Most WebSocket libraries handle ping/pong automatically. If you are implementing a custom client, ensure pong responses are sent.

Reconnection Strategy

Clients should implement exponential backoff when the connection drops:

let retryDelay = 1000; // Start at 1 second

function connect() {
const ws = new WebSocket(`wss://vault.example.com/api/sync/ws?token=${token}`);

ws.onopen = () => {
retryDelay = 1000; // Reset on success
ws.send(JSON.stringify({
type: "sync_request",
last_sync: getLastSyncTimestamp(),
}));
};

ws.onclose = () => {
setTimeout(() => {
retryDelay = Math.min(retryDelay * 2, 30000); // Max 30 seconds
connect();
}, retryDelay);
};
}

connect();

On reconnection, the client sends a fresh sync_request with the last known sync timestamp to catch up on any changes missed while disconnected.

Token Refresh

The WebSocket connection is authenticated with the same access token used for REST API calls. When the token is about to expire, the client should:

  1. Refresh the token via POST /api/auth/refresh.
  2. Close the existing WebSocket connection.
  3. Open a new connection with the fresh token.

The server closes connections whose tokens have expired with close code 4001 (token expired).