Featurevisor

Real-time updates with PartyKit

Archived page

This guide is kept for reference and is no longer maintained. Deployment compares the ways applications get new datafiles.

Tell running applications the moment new datafiles are deployed, so they fetch them at once instead of waiting for their next refresh. A PartyKit server passes the message from your pipeline to every application over a WebSocket.

A rollout or a rollback then reaches users within seconds. Applications keep a slower refresh as well, for any message they miss.

How it works

  1. Your pipeline builds and deploys the datafiles, then sends a message to the PartyKit server.
  2. The server sends it on to every connected application.
  3. Each application fetches the new datafile and gives it to its SDK instance.

Create the server

Create a project with the current PartyKit starter:

Command
npm create partykit@latest

In the generated project, replace the server entry point named by main in partykit.json with the following code. This example uses party/index.ts and the current Party.Server API:

party/index.ts
import type * as Party from 'partykit/server'
export default class Server implements Party.Server {
constructor(readonly room: Party.Room) {}
async onRequest(request: Party.Request) {
if (request.method !== 'POST') {
return new Response('Method not allowed', {
status: 405,
headers: { Allow: 'POST' },
})
}
const secret = this.room.env.PARTY_SECRET
if (typeof secret !== 'string' || !secret) {
return new Response('Server secret is not configured', { status: 503 })
}
if (request.headers.get('X-PartyKit-Secret') !== secret) {
return new Response('Unauthorized', { status: 401 })
}
this.room.broadcast(JSON.stringify({ type: 'refresh' }))
return new Response('Refresh notification sent')
}
}

Use PartyKit's environment variable management to store a strong random secret. Run from the generated project directory and enter the secret at the prompt:

Command
npx partykit env add PARTY_SECRET

Set the same value as PARTY_SECRET in your GitHub Actions secret store. Never put it in the browser bundle or commit it to the repository. PartyKit exposes the deployed value through room.env, not the browser's environment. Environment changes take effect on the next deployment:

Command
npx partykit deploy

For local testing, follow PartyKit's environment setup and run npx partykit dev from this directory. The server has no onMessage broadcast handler, so browser clients cannot trigger deployment broadcasts. The WebSocket subscription is public in this example and carries only a refresh notification. Add connection authentication if even deployment timing is private.

Notify it from your pipeline

Add a step after the upload in your publish workflow, such as the one from GitHub Actions or Cloudflare Pages. Any CI tool that can send an HTTP request works the same way:

.github/workflows/publish.yml
# ...
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# ...
# add new step here after uploading the generated datafiles
- name: Send message to PartyKit
env:
PARTY_SECRET: ${{ secrets.PARTY_SECRET }}
PARTYKIT_URL: ${{ vars.PARTYKIT_URL }}
run: |
curl --fail-with-body --show-error --silent \
--connect-timeout 10 --max-time 30 \
-X POST \
-H "X-PartyKit-Secret: ${PARTY_SECRET:?Set PARTY_SECRET}" \
"${PARTYKIT_URL:?Set PARTYKIT_URL}"

Set the repository variable PARTYKIT_URL to https://<project>.<username>.partykit.dev/party/featurevisor, using the host printed by deployment. The last path component is the room ID. Use the same room in the browser, and separate rooms and datafile URLs for different environments and targets.

Send this notification only after the new datafile is available through the CDN, including any cache invalidation. The request needs no body: the server constructs the notification after authenticating it. HTTP errors fail this step.

Listen in your application

Copy the Browser startup example and its shared datafile-refresh.mjs helper into your application, with the generated datafile.json. Configure the URL and cache key. The example defines f and a local refreshAndCache() wrapper that retains the last accepted configuration on failure.

Replace its final void refreshAndCache() line with the code below in the same file. The shared helper already applies a fetch timeout. Adapt it for older browsers if needed. Do not add another refresh timer.

your-app/index.js
const WEBSOCKET_URL =
'wss://<project>.<username>.partykit.dev/party/featurevisor'
let stopped = false
let socket
let reconnectTimer
let retryDelay = 1000
let refreshRequested = false
let pendingRefresh
function requestRefresh() {
if (stopped) return
refreshRequested = true
if (!pendingRefresh) {
pendingRefresh = (async () => {
while (refreshRequested && !stopped) {
refreshRequested = false
await refreshAndCache()
}
})().finally(() => { pendingRefresh = undefined })
}
return pendingRefresh
}
function connect() {
if (stopped) return
socket = new WebSocket(WEBSOCKET_URL)
socket.onopen = () => {
retryDelay = 1000
void requestRefresh()
}
socket.onmessage = async (event) => {
try {
const message = JSON.parse(event.data)
if (message?.type === 'refresh') await requestRefresh()
} catch (error) {
console.warn('Invalid refresh notification:', error)
}
}
socket.onerror = () => socket.close()
socket.onclose = () => {
if (stopped) return
reconnectTimer = setTimeout(connect, retryDelay + Math.random() * 1000)
retryDelay = Math.min(retryDelay * 2, 30000)
}
}
void requestRefresh()
connect()
const fallbackTimer = setInterval(() => { void requestRefresh() }, 5 * 60 * 1000)
// Call from your application's disposal hook, after evaluation consumers stop
export async function stopFeaturevisor() {
stopped = true
clearTimeout(reconnectTimer)
clearInterval(fallbackTimer)
socket?.close()
await pendingRefresh
await f.close()
}

Modern browsers provide the WebSocket API, so this client does not need an additional library. Reconnecting uses bounded backoff with jitter. Every connection opening refreshes the datafile to recover notifications missed while disconnected, and the fallback interval retries after CDN failures even without another deployment.

The queue schedules another fetch when a notification arrives during a refresh, instead of losing that update to the Browser helper's concurrent fetch guard. Neither notifications nor refreshes automatically rerender your UI. Subscribe through your framework integration or the SDK's datafile events.

Node.js 22 and later have WebSocket built in, so a server can listen the same way, refreshing with the Node.js helper rather than the browser's cache.