HTTP provider for Yjs. Syncs a shared document over plain http requests instead of a websocket.
Every syncInterval milliseconds — and about debounce milliseconds after you stop typing —
yhub-http-fallback performs a sync round: it PATCHes the local changes and GETs the whole
remote document, which is merged into the local document. Merging is what Yjs is good at, so
pulling the full document is safe and idempotent, no matter how long a client was away.
- Works wherever
fetchworks — no websocket upgrade, no long-lived connection - Syncs awareness (presence), not just the document
- Speaks y/hub's rest api, so the same server serves your websocket clients and your http clients over the same rooms
- Designed to sit next to y-websocket as a fallback for clients that cannot open a socket
If you can run a websocket, run a websocket. yhub-http-fallback exists for the cases where you cannot: corporate proxies and captive portals that block upgrades, backends that only speak rest, and serverless deployments that have nowhere to keep a connection.
Be honest with yourself about what polling can and cannot do.
- Remote changes arrive after up to
syncInterval. Local changes go out aboutdebouncemilliseconds after the last one, so writes feel immediate — but you will not see someone else's keystrokes until the next round. - Every round transfers the whole document. y/hub's read endpoint has no state-vector diff, so
a 200 kB document polled every 10 seconds by 10 clients is a few megabytes of egress per minute.
Raise
syncIntervalfor large documents. - Awareness lags, and expires. Awareness states that are not refreshed for 30 seconds are
dropped, so
syncIntervalis capped at 15 seconds while awareness is enabled. Even then, cursor movement is only visible at poll resolution — good enough for "who is here", not for smooth remote carets. - There is no push channel. The server cannot notify you, so there is no equivalent of
y-websocket's
connection-close. Permanent failures still surface — as aclosedevent on the round that hit them, rather than as a close frame. - One poll loop per tab. yhub-http-fallback has no cross-tab BroadcastChannel support, so five open tabs mean five poll loops. y-websocket deduplicates tabs; yhub-http-fallback does not.
npm i --save @y/yhub-http-fallbackimport * as Y from 'yjs'
import { HttpProvider } from '@y/yhub-http-fallback'
const doc = new Y.Doc()
const provider = new HttpProvider(doc, 'https://my-hub.example.com/api', {
org: 'my-org',
docid: 'my-document-name'
})
provider.on('sync', () => {
console.log('the document has been retrieved')
})That is the whole configuration: the api endpoint, and the room that identifies the document. The
same y/hub serves your websocket clients at
wss://my-hub.example.com/api/ws/v1/my-org/my-document-name and your http clients at
https://my-hub.example.com/api/ydoc/v1/my-org/my-document-name — same rooms, same document
state, same authorization.
Authorization goes into params or headers, whichever your deployment reads. Both are plain
objects on the provider and are read on every request, so refreshing a token is an assignment:
const provider = new HttpProvider(doc, 'https://my-hub.example.com/api', room, {
params: { yauth: token } // ...or headers: { authorization: `Bearer ${token}` }
})
// `closed` fires when retrying would be pointless - see "Errors" below
provider.on('closed', async ({ code }) => {
if (code === 401 || code === 403) {
provider.params.yauth = await mintNewToken() // picked up by the next request
provider.connect() // resume deliberately
}
})Every failed round is either transient — retrying may succeed — or permanent, meaning the request keeps failing until your application does something about it. yhub-http-fallback applies the rule that y/hub documents for its rest api, and that y-websocket applies to close codes:
| Status | Meaning | Retry? |
|---|---|---|
5xx |
server-side failure — 500 internal error, 503 a dependency is temporarily down |
yes, with backoff |
429 |
rate limited — Retry-After is honoured when present |
yes, after the delay |
every other 4xx |
400 404 409 422 caller mistake, 401 403 unauthenticated / no access |
no — fix the request or obtain fresh credentials |
| anything that is not a response | a dropped connection, dns, a timeout | yes, with backoff |
A transient failure emits connection-error and the provider keeps polling, backing off
exponentially up to maxBackoffTime.
A permanent failure emits connection-error and closed, and the provider stops:
shouldConnect becomes false, no further requests are made, and unpublished changes are kept.
It is not destroyed — act, then resume deliberately:
provider.on('closed', async ({ code, reason }) => {
console.warn(`yhub-http-fallback gave up: ${code} ${reason}`)
if (code === 401 || code === 403) {
provider.headers.authorization = `Bearer ${await mintNewToken()}`
provider.connect()
}
})shouldRetry implements the table above by default. Override it to opt out entirely, or to
classify what your backend returns:
const provider = new HttpProvider(doc, serverUrl, room, {
shouldRetry: () => true // never give up
})It is deliberately written as a negation — an error that does not classify itself is transient — so a dropped connection never stops the provider by accident.
Some networks block websocket upgrades, and some deployments occasionally lose their websocket
backend. yhub-http-fallback can stand in: both providers work on the same Y.Doc and the
same Awareness instance, and a small helper makes sure only one of them is connected at a
time.
import * as Y from 'yjs'
import { Awareness } from 'y-protocols/awareness'
import { WebsocketProvider } from 'y-websocket'
import { HttpProvider, createWebsocketFallback } from '@y/yhub-http-fallback'
const doc = new Y.Doc()
// one Awareness instance, shared by both transports
const awareness = new Awareness(doc)
const wsProvider = new WebsocketProvider(
'wss://my-hub.example.com/api/ws/v1/my-org', 'my-document-name', doc,
{ awareness }
)
const httpProvider = new HttpProvider(
doc,
'https://my-hub.example.com/api',
{ org: 'my-org', docid: 'my-document-name' },
{ awareness, connect: false } // the fallback helper decides when to poll
)
// poll over http from the moment the websocket is closed until it is established again
const stopFallback = createWebsocketFallback(wsProvider, httpProvider)Three things matter here:
- The same
Y.Doc. Nothing has to be flushed when the transport changes: whatever yhub-http-fallback retrieved while the websocket was down is part of the document, and the websocket's sync step publishes it as soon as the connection is back. Merging is what Yjs does. - The same
Awarenessinstance. Awareness state is keyed bydoc.clientID, which belongs to the document. TwoAwarenessinstances on one document would advertise the same client id with independent clocks and fight over the local state. Pass one instance to both providers so your cursor is published by whichever transport is connected, and so your editor binding sees remote cursors from both. connect: false. The helper owns the http provider's connection state. Don't callhttpProvider.connect()yourself while it is installed.
Note how the same room is spelled differently by the two providers. y-websocket splits it into a server url and a room name, yhub-http-fallback takes it apart explicitly:
new WebsocketProvider(`wss://${host}/api/ws/v1/${org}`, docid, doc, { awareness })
new HttpProvider(doc, `https://${host}/api`, { org, docid }, { awareness })What the helper does, exactly:
- While the websocket works, the http provider is disconnected — no requests at all.
- The websocket is closed → the http provider starts, on the first close. No grace period, no waiting for a second failure. This is the case the helper exists for: a network that kills websockets kills them every time, so waiting only delays the inevitable. The one exception is a close code that explicitly means "try again later" — see below.
- The websocket is established again → the http provider stops, and its in-flight request is
aborted. This happens on the
connectedstatus, without waiting for the sync step to finish. - Nothing is emitted at all → the http provider takes over after
timeout. Some networks black-hole a connection instead of closing it, so no event ever arrives. The same timer covers a socket that connects but never completes its sync step. - A provider that gave up is retried every
retryInterval. After a permanent close y-websocket setsshouldConnect = falseand never reconnects on its own, so without this the fallback would be a one-way trip — polling forever even after the problem was fixed. The same applies to the http provider when it fails permanently: it is retried, not abandoned.
shouldFallback decides. By default every close does, except the ones where the server explicitly
said it will be back:
| Close code | Fallback? | |
|---|---|---|
1011 |
internal error | no — wait out timeout |
1013 |
try again later | no — wait out timeout |
4500-4599 |
y/hub's transient range | no — wait out timeout |
1006 |
no close frame — a firewall, a proxy, a dropped link, a killed server | yes, immediately |
| no code at all | a WebSocket polyfill that does not report one | yes, immediately |
| everything else | 4400-4499 permanent, 1001 going away, … |
yes, immediately |
Note which side of the line 1006 sits on. It is transient as far as reconnecting goes — that
is why y-websocket keeps retrying it — but it is the exact signature of a network that does not
allow websockets, and there is nothing to be gained by sitting on our hands while it retries. The
two predicates answer different questions: shouldReconnect asks "should the socket try again?",
shouldFallback asks "should we start polling in the meantime?".
1006 is also all a browser will ever tell you. CloseEvent.code is always a number — it is
1006 whenever no close frame arrived — and the reason behind a refused upgrade is deliberately
hidden from javascript, so a 403 from a proxy appears only in the devtools console. That is why
the default is written as a negation: anything that is not a recognised "try again later" starts
the fallback, including a close that reports no code at all.
The cost of being eager is one http round trip on an ordinary blip that the websocket would have recovered from anyway. That is the trade: a few kilobytes against a user who cannot type. Override it if your network says otherwise:
createWebsocketFallback(wsProvider, httpProvider, {
// only fall back when the websocket gave up entirely
shouldFallback: event => event.code >= 4400 && event.code < 4500
})A connection that you closed — wsProvider.disconnect() — never starts the fallback. A
connection that y-websocket's watchdog closed because it stopped responding does, since that is a
dead link by another name. Both arrive as a null event; shouldConnect is what tells them
apart.
Nothing here takes the decision away from you. connect() on a provider that is already trying is
a no-op, so the retry only revives one that stopped, and an application that handles closed
itself — refreshing a token and calling connect() — recovers immediately instead of waiting for
the next retry. Treat retryInterval as the safety net, not the mechanism.
The helper wants a reasonably recent y-websocket. Since
y-websocket@3.1.0 (and @y/websocket@4 for Yjs v14), a
close code in the 4400-4499 range makes the provider stop reconnecting and emit closed;
yhub-http-fallback emits the same event, with the http status as code. Both are the "retrying is
pointless" signal described in Errors, and the helper listens to both.
It does not import y-websocket. The first argument needs synced, shouldConnect and
connect(), the second shouldConnect, connect() and disconnect(); on()/off() on the
first are optional. So it works with y-webrtc, with the hocuspocus/tiptap providers, or with your
own — without events it falls back to re-checking every retryInterval, which costs nothing but a
little latency when handing back.
One caveat worth stating plainly: a permanent websocket failure is often an auth failure, and http
will hit the same wall. That is fine, and it is why the two halves fit together —
yhub-http-fallback tries, gets its own permanent error, stops, and emits closed. Handle that
event and you have one place to re-authenticate, whichever transport noticed first.
createWebsocketFallback returns a function that uninstalls it. It removes the listeners and
clears the timer, and deliberately leaves both providers' connection state untouched:
stopFallback()Nothing to do. The same host serves both endpoints over the same rooms:
wss://{host}/api/ws/v1/{org}/{docid}— y-websockethttps://{host}/api/ydoc/v1/{org}/{docid}— yhub-http-fallback
They share document state, awareness and authorization. A token that works as a query parameter or
header on the websocket works on the rest endpoint too, and updates that a fallback client
PATCHes are distributed to the connected websocket clients immediately — and the other way
round.
yhub-http-fallback speaks two requests against one url,
{serverUrl}/ydoc/v1/{org}/{docid}?branch=&gc=&awareness=. Bodies are
lib0 any-encoded (buffer.encodeAny / buffer.decodeAny) — not
json, not base64 — with content-type: application/octet-stream on requests.
GET |
Answer { doc: Uint8Array, awareness?: Uint8Array }. doc is the whole document, Y.encodeStateAsUpdate(doc). awareness is bare encodeAwarenessUpdate(...) output — no message type prefix — and is only asked for when awareness=true. |
PATCH |
The body is { update?: Uint8Array, awareness?: Uint8Array }, at least one of them present. Apply what is there. The response body is ignored. |
| errors | Any non-2xx status. A lib0-any { error: string } body is used as the message. The status decides whether the provider retries — see Errors. |
The query parameters are branch (default main), gc (default false) and awareness. Ignore
the ones you don't implement.
Serving that from a y-websocket server, whose
documents are already in memory behind getYDoc(docname), is a handful of lines on the same
http.Server the websocket server is attached to:
import * as http from 'node:http'
import * as Y from 'yjs'
import * as buffer from 'lib0/buffer'
import { applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness'
import { getYDoc, setupWSConnection } from '@y/websocket-server/utils'
import { WebSocketServer } from 'ws'
const server = http.createServer(() => {})
server.on('request', async (req, res) => {
const url = new URL(req.url, 'http://localhost')
const match = /^\/api\/ydoc\/v1\/([^/]+)\/([^/]+)$/.exec(url.pathname)
if (match == null) return
// a y-websocket room is one string - map the {org}/{docid} pair onto it however you like
const doc = getYDoc(`${match[1]}/${match[2]}`)
const respond = body => {
res.writeHead(200, { 'content-type': 'application/x-lib0any' })
res.end(Buffer.from(buffer.encodeAny(body)))
}
if (req.method === 'PATCH') {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const { update, awareness } = buffer.decodeAny(new Uint8Array(Buffer.concat(chunks)))
// the same WSSharedDoc the websocket server uses, so this reaches every connected client
if (update != null) Y.applyUpdate(doc, update, 'http')
if (awareness != null) applyAwarenessUpdate(doc.awareness, awareness, 'http')
respond({ success: true })
return
}
const body = { doc: Y.encodeStateAsUpdate(doc) }
if (url.searchParams.get('awareness') === 'true') {
const clients = Array.from(doc.awareness.getStates().keys())
if (clients.length > 0) body.awareness = encodeAwarenessUpdate(doc.awareness, clients)
}
respond(body)
})
const wss = new WebSocketServer({ noServer: true })
wss.on('connection', setupWSConnection)
server.on('upgrade', (req, socket, head) =>
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req)))
server.listen(1234)Point the client at it with new HttpProvider(doc, 'http://localhost:1234/api', { org, docid }).
Use @y/websocket-server@0.1.1 for Yjs v13 — later versions target Yjs v14. The older
y-websocket@2 shipped the same utilities at y-websocket/bin/utils; those are CommonJS, so a
server file that mixes import * as Y from 'yjs' with require('y-websocket/bin/utils') ends up
with two Yjs instances. Updates then fail Yjs' constructor checks and are silently dropped —
awareness keeps working, which makes it look like a sync bug.
If your page is served from another origin, answer the OPTIONS preflight and send
access-control-allow-origin / access-control-allow-headers — application/octet-stream is not
a cors-simple content type.
- A permanent failure on one transport usually means a permanent failure on the other. Listen to
closedon both providers and re-authenticate in one place. - Remote cursors flicker. y-websocket removes every remote awareness state when the socket drops; the next http round adds them back.
- Nothing is lost in either direction. Both providers publish from the same document, and Yjs merges.
- Latency changes, visibly. Say so in your ui if your users care —
httpProvider.shouldConnectis the honest answer to "are we on the fallback right now". - A brief blip costs an http round trip. The fallback starts on the first close and stops when the socket is back, so a two-second reconnect usually means one poll.
- The failover is not one-way. The websocket keeps being retried underneath, so a client that fell back during an outage returns to the socket on its own.
-
Create an http provider for
ydoc.serverUrlis the api endpoint, e.g.https://my-hub.example.com/api.roomis{ org: string, docid: string, branch?: string }—branchdefaults to'main'.
provider = new HttpProvider(ydoc: Y.Doc, serverUrl: string, room: Room [, opts: Options])
opts = {
awareness: new Awareness(ydoc), // specify `null` to disable awareness
connect: true, // start syncing immediately
syncInterval: 10000, // sync at most this often, and never wait longer than this to publish
debounce: 1000, // publish local changes this long after the last one
maxBackoffTime: 60000, // the longest delay between two failing rounds
timeout: 30000, // abort a round that takes longer than this. `0` disables it
// Retrieve the garbage-collected document instead of the full history. Off by default: the
// server's history features only work while the tombstones are still there.
gc: false,
// Decide whether a failed round is worth retrying. By default a `4xx` other than `429` is
// permanent: the provider stops syncing and fires `closed`. See "Errors" above.
shouldRetry: (error, provider) => error.retryable !== false,
params: {}, // query parameters, read on every request
headers: {}, // request headers, read on every request
fetch: globalThis.fetch
}syncInterval and debounce together describe when local changes are published: about debounce
milliseconds after you stop typing, and — while you keep typing — at least every syncInterval
milliseconds. A burst of changes results in a single request. syncInterval is also the polling
interval, so it is how long a remote change may take to reach you.
gc selects which variant of the remote document is retrieved, and is deliberately not tied
to doc.gc. It defaults to false, so the full history comes down: deleted content is only
reconstructible while its tombstones are still around, which is what history, attribution and
rollback features are built on. Set gc: true to retrieve the smaller garbage-collected variant
instead. Either way your local document garbage-collects according to its own doc.gc.
- The
Y.Docthat is being synced. - The
Awarenessinstance, ornullwhen awareness is disabled. The provider never destroys it — it may be shared with another provider, and it destroys itself when the document is destroyed. - The api endpoint, and the resolved
{ org, docid, branch }. - The full document url including the current query parameters — useful when debugging.
- Whether the document has been retrieved at least once since the provider connected. A single
failing round does not reset this — use
status,connection-errorandclosedto observe whether the remote is currently reachable. - A promise that resolves with the provider once the document has been retrieved for the first time.
'connecting','connected'or'disconnected'.- Whether the provider is supposed to keep syncing. Becomes
falseafterdisconnect(), afterdestroy(), and after a permanent failure. - Plain objects that are read on every request. Update them to rotate an auth token without recreating the provider.
- May be changed at runtime — to poll less often while the tab is hidden, for example.
- Perform a round right now, no matter when the next one was scheduled. Resolves once the response has been applied and rejects when the round failed. Concurrent calls share a single round. Changes made in the transaction that called it are included.
- Start syncing. Idempotent, and the way to resume after a permanent failure.
- Stop syncing and abort the in-flight request. Unpublished changes are kept and are published when the provider connects again.
- Stop syncing and detach every listener. Publishes a
nullawareness state first, best effort, so that remote clients see you leave instead of waiting for the state to time out. Idempotent, and called automatically when the document is destroyed. The document and the awareness instance are left alone — both may be shared with another provider. - Fires once per connection, when the document has been retrieved for the first time. It does
not fire again when a later round fails or succeeds.
disconnect()emitsfalse, so a followingconnect()emitstrueagain.'synced'is an alias, for symmetry with y-websocket. - Fires when the connection status changes. Successful rounds while already connected are silent.
- Fires on every failed round, transient or not. Unsuccessful responses are
HttpErrors and carrystatus,statusText,url,retryableandretryAfter. - Fires when a round failed and
shouldRetryreturned false —codeis the http status. The provider stops syncing (shouldConnectbecomes false) but is not destroyed: unpublished changes are kept, and you may resume deliberately withprovider.connect()or clean up withprovider.destroy(). Unlikeconnection-error, which fires on every blip, this fires only when retrying is pointless — and why. See Errors.
provider.doc
provider.awareness
provider.serverUrl, provider.room
provider.url
provider.synced
provider.whenSynced
provider.status
provider.shouldConnect
provider.params, provider.headers
provider.syncInterval, provider.debounce
provider.sync(): Promise<void>
provider.connect()
provider.disconnect()
provider.destroy()
provider.on('sync', function(isSynced: boolean))
provider.on('status', function({ status: 'connecting' | 'connected' | 'disconnected' }))
provider.on('connection-error', function(error: Error, provider: HttpProvider))
provider.on('closed', function({ code: number, reason: string }, provider))
There is no 'connection-close' event: http has no push channel, so there is nothing to report.
- Run
httpProvideronly whilewsProvideris not working, and stop it again as soon as the websocket connection is established.opts.shouldFallback(event, primary)decides which closes start it — by default every close except1011,1013and4500-4599.opts.timeout(default5000) covers a connection that is black-holed rather than closed.opts.retryInterval(default30000) gives a provider that gave up another chance, so the fallback is never a one-way trip. Returns a function that uninstalls the helper; both providers keep whatever connection state they have. - The error emitted for an unsuccessful response.
stop = createWebsocketFallback(wsProvider, httpProvider, opts?)
HttpError
Subdocuments. One provider per loaded subdocument, addressed by its guid:
doc.on('subdocs', ({ loaded }) => loaded.forEach(sub => {
new HttpProvider(sub, serverUrl, { org, docid: sub.guid }, { awareness: null })
}))Poll less often in a background tab.
document.addEventListener('visibilitychange', () => {
provider.syncInterval = document.visibilityState === 'hidden' ? 60000 : 10000
})Older runtimes. fetch and AbortController are expected to be global (node 18+). Pass
opts.fetch to supply your own, or to route requests through a proxy agent.
npm test # runs an in-process fake y/hub and exercises a real client against it
npm run lint # standard + tscThere is one live test against a real y/hub, skipped unless you point it at one:
YHUB_URL=http://localhost:3002/api YHUB_ORG=testOrg npm testThe MIT License © Kevin Jahns