Every client in sync. One server in charge.
rifleDB is a realtime database for apps people use together. Read and write over REST, subscribe to the same paths over WebSocket, and let folders and server actions decide who may change what.
One Node.js server with Redis behind it, an SDK for React, Next.js and plain TypeScript, and an admin app to watch it all run.
- A visitor drops a platePOST /v1/demo/world/@drop{ "q": 1, "r": 3, "type": "l" }
- The server merged three platesdemo/world/col/-3_2["t0", "f0", "c2"]
Three things to build before your first feature.
Keeping every client in sync
Usually: A socket server of your own, reconnect and resubscribe logic, and optimistic updates that roll back when the server says no.
With rifleDB: Subscribe to a path. Writes show up at once, reconcile with the server’s answer, and replay after a reconnect.
Deciding who may read and write
Usually: Sign-in, sessions, and the same permission checks written twice: once for the HTTP API, once for the socket.
With rifleDB: Every path lives in a public, private or secret folder. Clients sign with device keys, and one set of rules covers REST and WebSocket.
Gluing the realtime parts together
Usually: Fan-out to the right clients, throttling for cursors and sliders, an HTTP API for scripts, and checks that stop a client from writing a score it didn’t earn.
With rifleDB: Fast folders coalesce what moves, every path is also a REST URL, and server actions decide what clients may only ask for.
How it works
One path, two ways in.
Every value lives at a path like polls/launch/votes. Browsers subscribe over one WebSocket per tab; scripts, cron jobs and curl use plain HTTP with ETags and problem+json errors.
Both reach the same data, under the same rules, in the same order. No polling anywhere: changes are pushed as they commit.
Over HTTP, at /v1/polls/launch/votes
- GET
- Read it.
?prefix=1lists a subtree,?stream=1streams changes. - PUT
- Set it. With
If-Matchit only lands if nobody changed it first. - PATCH
- Apply an op such as
inc,pushormerge, or a JSON merge patch. - POST
- Push onto a list.
POST /v1/<folder>/@<action>runs an action. - DELETE
- Delete it.
Over WebSocket
- sub { path: 'polls/launch/votes' }
- The current value, then every change as it commits.
- op { id, path, op: 'inc' }
- Write. The ack carries the new revision; a retried id is not applied twice (within five minutes).
- act { id, folder, name: 'vote', args }
- Run a server action as one transaction.
- chg { f: folder, e: [[path, value, rev, flags]] }
- Pushed to every subscriber once the write is committed (fast folders: at once).
Folders decide who gets in.
Every path belongs to a folder, and the folder’s mode sets the rules. Clients prove who they are with Ed25519 device keys the SDK creates in the browser. No passwords, no cookies.
Owners share their folders themselves with rifle.share: folder keys for a device or a service, grants per person, and invite links whose secret rides in the URL fragment. Each one can be taken back the same way.
Secret means access-controlled, not end-to-end encrypted. The server stores the data and could read it; the admin app only ever shows metadata and can wipe it.
- PublicAnyone reads. Writes stay off until you set
publicWriteor route them through an action. - PrivateEvery read and write needs a signature: the owner’s device, a folder key, or a grant.
- SecretOnly the owner, from one of their devices, after a passkey check in the last ten minutes. Keys, grants and invites don’t apply, so a secret folder can never be shared.
- users/u_8fQ2xVprivatethe user’s own data
- users/u_8fQ2xV/profilepublicanyone reads, the owner writes
- users/u_8fQ2xV/secretsecretowner plus passkey step-up
- users/u_8fQ2xV/serverprivatethe user reads, only the server writes
Fast folders for things that move.
Cursors, sliders, toggles, who’s online. A fast folder lives in memory: writes are acknowledged and broadcast at once, and bursts are coalesced per path (30 times a second by default), so a dragged slider doesn’t flood anyone.
- Primitives and arrays of primitives only, up to 2 KB per value.
persist: truesnapshots to Redis every two seconds;persist: falsenever touches it. ttlMsexpires stale keys andonDisconnectcleans up when a tab closes.usePresencewraps both for cursors and avatars, and withpublicWrite='own'nobody moves anyone else’s cursor.
- rooms/lobby/volume
- 72
- rooms/lobby/playing
- true
- rooms/lobby/cursor/s_4kq
- [412, 188, 204]
- rooms/lobby/track
- 'side-b'
<Folder
path='rooms'
mode='public'
publicWrite
fast={{ persist: false, ttlMs: 15_000 }}
/>The live demo runs on exactly this.
How a plate lands
- A visitor calls
dropwith a cell and a plate type, over the socket or asPOST /v1/demo/world/@drop. - The server checks the arguments against the schema, then the limits: four drops a second per player, forty per network address.
- The handler runs the island rules, seeded from the world’s own counter: the plate lands, reacts with the one below, merges with its neighbors.
- Every changed column is written in one commit and one broadcast. If the handler throws, nothing is written.
- One
dropevent with everything that happened reaches the other visitors only after the commit, so nobody sees a merge that didn’t happen. The visitor’s own browser ran the same rules with the same seed and showed the plate at once.
import { Action, Folder } from '@rifledb/server'
import { drop, dropRequestSchema, dropSeed, seaLevelFor } from '@rifledb/demo-world'
import type { DropEventData, Meta } from '@rifledb/demo-world'
<Folder path='demo/world' mode='public' authority={['**']}>
<Action
name='drop'
args={dropRequestSchema} // { q, r, type: a tier-0 plate, n?: the caller's nonce }
access='public'
handler={(ctx, { q, r, type, n }) => {
limitDrops(ctx) // 4 a second per player, 40 per network address
const { columns, meta } = readWorld(ctx) // every col/<q>_<r>, and meta
const seed = dropSeed(meta) // seedFrom(season, plates): browsers predict the same result
const result = drop(columns, { q, r, type }, { seed })
for (const [cell, column] of Object.entries(result.columns)) {
ctx.op(`col/${cell}`, 'set', { value: column })
}
const plates = meta[1] + 1
const next: Meta = [meta[0], plates, seaLevelFor(plates), meta[3]]
ctx.op('meta', 'set', { value: next })
// One event per drop: everything that happened, in order (landed, alchemy, merged…)
const event: DropEventData = { seed, q, r, type, events: result.events, meta: next }
if (n !== undefined) event.n = n // event data must be JSON: no undefined values
ctx.emit('drop', event)
return { placed: result.placed, seed, events: result.events, meta: next }
}}
/>
</Folder>The demo’s drop action as the server runs it. limitDrops and readWorld are two small helpers next to it.
See inside the server while it runs.
A performance dashboard built in
The server samples itself once a second: ops per second and errors, ack and persist latency percentiles, connections and subscriptions, heap, event-loop delay and the Redis round trip. Every minute a record is kept for seven days, and the busiest folders and the live op stream are one click away.
A benchmark suite that knows its noise
A benchmark suite of micro benchmarks and workload scenarios (fan-out, presence, REST, actions, reconnects) compares two runs and calls a change slower only when it is larger than the noise. Its first baseline run is still to come, so there are no numbers to quote yet. How it measures.
rifleDB or gun.eco?
| Aspect | rifleDB | gun.eco |
|---|---|---|
| Shape | rifleDB: One authoritative server (Node.js and Redis) that you run. | gun.eco: A peer-to-peer mesh of browsers and relay peers. |
| Who decides | rifleDB: The server orders every write with one revision counter. Compare-and-set with If-Match. | gun.eco: Each peer merges updates with its conflict-resolution algorithm (HAM). |
| App rules | rifleDB: Schemas, authority paths and transactional actions, enforced on the server. | gun.eco: Each peer decides what it accepts. There is no central referee for shared rules. |
| Access | rifleDB: Public, private and secret folders, with signing keys, grants and invite links. | gun.eco: A public graph plus signed user spaces. Private data is shared by encrypting it to a key. |
| Identity | rifleDB: Ed25519 device keys kept in the browser, passkeys for step-up. | gun.eco: SEA key pairs, commonly unlocked with an alias and a password. |
| Offline | rifleDB: Cached reads. Queued writes replay on reconnect, and the server can still reject them. | gun.eco: Offline-first: peers keep working locally and merge when they meet again. |
| Encryption | rifleDB: HTTPS and WSS in transit. Secret folders are access-controlled, not end-to-end encrypted. | gun.eco: Optional end-to-end encryption with SEA. |
| If the server is down | rifleDB: Nothing syncs until it is back: there is one server. Clients keep cached values and queue writes. If only Redis is down, normal-folder writes are refused while fast folders keep working. | gun.eco: No single point of failure: peers that can still reach each other keep syncing. |
| Maturity | rifleDB: New. Not published to npm yet and without a production track record. | gun.eco: An established open-source project, used in production for years, with a community around it. |
| Pick it when | rifleDB: Shared state needs one referee: multiplayer games, collaborative tools, live dashboards. | gun.eco: You want no central server, or data that only the users’ own keys can read. |
rifleDB does not encrypt data end to end and makes no zero-knowledge claims. If the server must not be able to read something, encrypt it on the client before you write it, or pick a tool built for that.
Get started in three steps.
Install the SDK
One package for the browser, React and Next.js. The npm release is coming; until then it installs from the rifleDB workspace. Setup has both ways.shellnpm install rifledbUse a path
useRiflesubscribes to a path and returns its value and the ops to change it. Your tab updates first and rolls back if the server says no.src/App.tsximport { createRifle } from 'rifledb' import { RifleProvider, useRifle } from 'rifledb/react' const rifle = createRifle({ url: 'http://localhost:4000' }) function Counter() { const [count, api] = useRifle('playground/counter') return <button onClick={() => api.inc()}>{count ?? 0} clicks</button> } export const App = () => ( <RifleProvider client={rifle}> <Counter /> </RifleProvider> )Run the server
It ships with an openplaygroundfolder, so the counter works right away for an app onlocalhost:3000or:5173. Open it in two tabs and click. Then declare your own folders, and add Redis when data should outlive a restart.shell# in the rifleDB workspace pnpm install pnpm -F @rifledb/server dev # http://localhost:4000, in memory