@convex-dev/batched-replay

中文文档
0.1.1发布于 2026/10/24Apache-2.0TypeScriptES Module零依赖构建来源已验证近 30 天下载 7625

Batch fast-changing values (cursors, pen strokes, game movement) into a few writes a second and replay them smoothly, live or later.

原始使用文档
文档目录10 节
  1. Install
  2. Expose an API
  3. Mirror into your own tables (onAppend)
  4. Send
  5. Play back
  6. Replay
  7. Cost
  8. Limits
  9. Testing
  10. Example

@convex-dev/batched-replay

npm version

Realtime-ish data on plain Convex. A sender batches fast-changing values (cursors, pen strokes, game movement) into one mutation every ~250 ms; watchers replay each batch with its original timing. It looks smooth and live, runs a little behind, and costs a handful of writes a second instead of 60. Batches are kept, so a whole session can be replayed later.

Live demo: https://helpful-wren-102.ap-southeast-2.convex.site (cursors, a shared drawing board, a ghost racer and a phone-tilt marble).

Found a bug? Feature request? File it here.

Two tabs: tab A draws a smiley, tab B replays each stroke about 300 ms behind, and both Strokes sidebars fill in from onAppend

The example's drawing board in two tabs, from about 4 writes a second.

Live demo: https://helpful-wren-102.ap-southeast-2.convex.site. Open it in two tabs, or open #marble on a laptop and steer it with your phone.

Install

npm install @convex-dev/batched-replay
// convex/convex.config.ts
import { defineApp } from "convex/server";
import batchedReplay from "@convex-dev/batched-replay/convex.config.js";

const app = defineApp();
app.use(batchedReplay);
export default app;

Expose an API

A room is a string you pick (a page, a board, a game). A stream is one sender's key inside it.

// convex/cursors.ts
import { v } from "convex/values";
import { components } from "./_generated/api";
import { exposeApi } from "@convex-dev/batched-replay";

export const { append, latest, history, streams } = exposeApi(
  components.batchedReplay,
  {
    // What one sample is. Checked on every append, and it types the hooks.
    // Leave it out and samples are v.any().
    value: v.object({ x: v.number(), y: v.number() }),
    // How long batches are kept after they end. Leave it out to keep them
    // forever (for replays).
    retentionMs: 10 * 60_000,
    // Throw to stop someone watching a room.
    authorizeRead: async (ctx) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity) throw new Error("Unauthorized");
    },
    // Return the stream key this caller writes to. `stream` is what the
    // client asked for; here it's only trusted as a per-tab session id.
    authorizeWrite: async (ctx, { stream }) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity) throw new Error("Unauthorized");
      return `${identity.tokenIdentifier}:${stream ?? "default"}`;
    },
  },
);

Both callbacks get the full query or mutation ctx, so they can check your own tables (is this user in this room?). Annotate ctx with your app's QueryCtx or MutationCtx from ./_generated/server for typed ctx.db access.

One stream key per tab. The server lays each stream's batches end to end on one timeline. If two devices (or two tabs) write the same key, their batches interleave and keep clamping against each other, so the stream jitters. Make the key the user plus a session id each tab picks for itself (for example crypto.randomUUID() kept in sessionStorage), as above.

usePlayback's self has to be that same key, so the client needs to know it. Build it on the client and pass it as stream (with authorizeWrite checking it starts with the signed-in user), or return it from a query of your own.

The example app has no sign-in, so its authorizeRead allows everyone and its authorizeWrite trusts the client's anonymous key. Don't ship that.

From your own functions you can also use the class: new BatchedReplay(components.batchedReplay) has append, latest, history, streams and clear. clear isn't in exposeApi, since it's destructive: wrap it in your own mutation with your own checks.

Mirror into your own tables (onAppend)

Batches are the component's data, made for playback. When your app wants its own durable copy of something, onAppend is the place: finished drawing strokes, game laps or checkpoints, sensor rollups and alerts, last-seen presence, moderation flags, or scheduling an agent when something finishes.

import { v } from "convex/values";
import { exposeApi } from "@convex-dev/batched-replay";
import { components } from "./_generated/api";
import type { MutationCtx } from "./_generated/server";

export const { append, latest, history, streams } = exposeApi(
  components.batchedReplay,
  {
    value: v.object({
      k: v.union(v.literal("down"), v.literal("move"), v.literal("up")),
      x: v.number(),
      y: v.number(),
    }),
    authorizeRead,
    authorizeWrite,
    // Called by append after the batch is stored, in the same transaction.
    onAppend: async (ctx: MutationCtx, batch) => {
      // It runs on every append: return early unless a stroke finished.
      const ups = batch.samples.filter((s) => s.v.k === "up");
      if (ups.length === 0) return;
      for (const up of ups) {
        await ctx.db.insert("penUps", {
          stream: batch.stream,
          seq: batch.seq,
          at: batch.start + up.t, // the sample's server time
          x: up.v.x,
          y: up.v.y,
        });
      }
    },
  },
);

batch has room, stream, seq, start, end, samples (typed by value) and dropped. samples are only the ones that were stored: dropped leading samples were left out because they were captured before the room was last cleared. It isn't called when nothing was stored. The drawing demo's example/convex/drawing.ts builds whole strokes this way.

The rules, most important first:

  • Write documents per stream or per user, never one shared room document. Everyone's appends (about 4 a second each) would then write the same document, conflict and retry, which brings back the write conflicts this component is built to avoid. For room totals, use @convex-dev/sharded-counter or @convex-dev/aggregate.
  • Filter first and return early, as above. It runs on every append.
  • A throw rolls back the append, and stalls that person's stream for everyone while their sender retries it. Wrap best-effort work in try/catch, or hand it to ctx.scheduler.runAfter(0, ...).
  • No duplicates to guard against. The hook runs once per stored batch, inside the same transaction, and Convex runs a mutation at most once.
  • Ordering is per stream, by seq. Batches from different streams can arrive in any order.
  • A batch is not an event. A stroke spans many batches, so collect it per stream, and expect partial ones: a stroke with no down (after a clear, or when a sender drops samples over a minute old), or a down that never gets its up.

From the BatchedReplay class there's no hook: append returns the same { seq, start, end, dropped } (or null), so do your mirroring on the next line of your mutation.

Send

import { useSender } from "@convex-dev/batched-replay/react";

const send = useSender(api.cursors.append, {
  room,
  stream: tabId,
  flushMs: 250, // one write per 250 ms while there's anything to send
  onError: (error) => console.warn(error), // it retries, with backoff
  onSend: (batch) => countWrite(), // as each batch goes out, e.g. for a HUD
});
onPointerMove((e) => send({ x: e.clientX, y: e.clientY }));
// Discrete events are never thinned out by sampleMs:
send(value, { keep: true });

It flushes on unmount and pagehide. A failed send keeps its samples and retries after flushMs, doubling up to 5 s. Samples over a minute old are dropped, since the server won't take a batch longer than that.

Play back

import { usePlayback } from "@convex-dev/batched-replay/react";

const { values, stats } = usePlayback(api.cursors, {
  room,
  self: tabId, // your own stream: draw your own input locally, with no delay
  interpolate: true,
});
// values: Map<stream, { x, y }> of everyone active in the last minute.
// stats: { delayMs, bufferedMs, batchesPerSecond, samplesPerSecond }

It watches the room's 100 most recently active streams; pass maxStreams (up to 1,000) for a bigger crowd.

It plays a little behind the server, by default just far enough to cover how late batches have been arriving (about 330 ms with a 250 ms flush), and never more than maxDelayMs (2 s). onSample(stream, value, time) fires for every sample, in order, even if the tab was hidden for a while. Missed batches are fetched from history and played in order.

For a drawing, where every stroke matters:

usePlayback(api.drawing, {
  room,
  self: tabId,
  // Replay the whole stored history through onSample first, instantly and
  // in order, then carry on live with no gap and nothing twice.
  fromStart: true,
  onSample: (stream, sample) => draw(stream, sample),
  // Someone cleared the room. With fromStart, the (now empty) history is
  // replayed again afterwards.
  onReset: () => clearCanvas(),
});

Replaying every batch gets slow once a room has a long history. If onAppend keeps your own copy of what it adds up to (the drawing demo keeps finished strokes, and each stream's stroke in progress), start from that instead. Have onAppend also store each stream's last seq, and return the copy from a query of your own, read in one transaction so the strokes and seqs agree:

const convex = useConvex();
usePlayback(api.drawing, {
  room,
  self: tabId,
  snapshot: {
    // On start, and again after every clear.
    load: () => convex.query(api.drawing.snapshot, {}),
    // Draw it, and say which batch it covers up to, for each stream.
    apply: (snapshot) => {
      drawSnapshot(snapshot);
      return snapshot.pens; // [{ stream, seq }, ...]
    },
  },
  onSample: (stream, sample) => draw(stream, sample),
  onReset: () => clearCanvas(),
});

Each listed stream then plays from the batch after its seq, however old, fetching any it missed from history, so nothing is drawn twice and nothing written while the snapshot loaded is lost. A stream the snapshot doesn't list plays from its first batch. apply isn't called if the room was cleared while the snapshot loaded; it's loaded again instead. See example/convex/drawing.ts and example/src/Drawing.tsx.

Replay

import { useHistory } from "@convex-dev/batched-replay/react";

const replay = useHistory(api.drawing, { room, speed: 10, onSample: draw });
replay.restart(); // load from the start, picking up new batches, and play
replay.pause(); // also play(), seek(ms), setSpeed(n)
// replay.position, replay.playing, replay.finished, replay.error

Idle stretches longer than maxGapMs (2 s) are cut short, so a timelapse doesn't sit through an hour of nothing. A failed page load retries after 1 s, doubling up to 10 s, and shows up as error.

The Sender, Player and HistoryPlayer classes behind the hooks are plain TypeScript, exported from @convex-dev/batched-replay, if you're not using React.

Cost

  • Writes: one append per sender per flush, so about 4 a second per person while they're moving, with a 250 ms flush. Nothing while they're idle.
  • Reads: every viewer subscribes to latest for each active stream. Each append runs that query once and Convex shares the result with every viewer, but each viewer's update counts as a function call: about senders x viewers x 4 a second. Ten people in a room all moving is about 400 a second; fifty is about 10,000. The stream list changes at most every 5 s per stream.
  • Abuse: a client can call append as fast as it likes. Put @convex-dev/rate-limiter in authorizeWrite (say, 10 appends a second per user) for anything public.
  • Retention: batches are stored until retentionMs after they end, then a cron deletes them each minute. Without retentionMs they're kept forever, so storage grows by every sample ever sent: one person moving a cursor at 60 samples a second is roughly 5 MB an hour. Keep forever only what you'll replay, like drawings.

Limits

Measured with a load test (N people in one room, each moving at 60 Hz with a 250 ms flush and watching everyone else) against a Convex cloud dev deployment, October 2026:

People in one roomWrite time (p50)Each viewer receives
10about 330 ms45 KB/s
25about 360 ms120 KB/s
50about 460 ms240 KB/s
100about 1.5 s460 KB/s

The test ran from Perth to a US region, so about 300 ms of each write is the network. There were no errors and no write conflicts at any size: each person only writes their own batches. It's comfortable up to about 50 people moving at once in one room. Past that, writes slow down, senders automatically send fewer, bigger batches, and playback falls further behind, rather than anything failing. Bandwidth is about 5 KB/s per viewer for each other person moving; rounding your values shrinks it.

Testing

To test your own functions with convex-test, register the component from @convex-dev/batched-replay/test:

/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import batchedReplay from "@convex-dev/batched-replay/test";
import schema from "./schema";

const modules = import.meta.glob("./**/*.ts");

export function initConvexTest() {
  const t = convexTest(schema, modules);
  batchedReplay.register(t);
  return t;
}

That export is TypeScript source that uses import.meta.glob, so your app needs convex-test, vitest and vite (for the vite/client types) in its dev dependencies.

Example

example/ has demos switched by the URL hash. Cursors (#cursors): open it in two tabs and move the mouse; a slider sets flushMs so you can compare smooth-but-behind with jumpy-and-expensive, and the HUD switches between three ways of showing the same batches: Jump (to the newest point), Smooth (a straight glide to the newest point over one flush interval) and Replay batches. Drawing (#drawing): a shared board where everyone else's strokes replay at the speed they were drawn, with a timelapse. A new window draws the board from the app's own strokes and pens tables, then carries on live. Racer (#racer): a tiny racing game where each lap is its own stream, so your best lap comes back as a ghost through useHistory. Marble (#marble, with #tilt on a phone): the phone's tilt is sent like a cursor and rolls a marble on the laptop. See example/README.md.

npm run dev
npm run dev:frontend
# 100 bot cursors on the cursors demo, for 60 s or until Ctrl-C
npm run crowd -- --bots 100 --flush 250 --seconds 60

npm run crowd connects each bot as its own client with its own stream key, sending through Sender like a browser tab. --seconds 0 runs until Ctrl-C, and --url defaults to VITE_CONVEX_URL in .env.local. It needs the build in dist/, which npm run dev keeps up to date.

See DESIGN.md for how it works.

版本与发布记录

@convex-dev/batched-replay 共发布 3 个版本,首次发布于 2026/10/24;过去 12 个月发布了 3 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.1.1latest2026/10/2417 天前
0.1.02026/10/2417 天前
0.0.0-stage2026/10/2417 天前

关于 @convex-dev/batched-replay 的常见问题

@convex-dev/batched-replay 是什么?

Batch fast-changing values (cursors, pen strokes, game movement) into a few writes a second and replay them smoothly, live or later.

如何安装 @convex-dev/batched-replay?

在项目目录运行 npm install @convex-dev/batched-replay,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:convex(^1.43.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@convex-dev/batched-replay 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@convex-dev/batched-replay 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@convex-dev/batched-replay 可以商用吗?

@convex-dev/batched-replay 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@convex-dev/batched-replay 的下载量有多少?

根据 npm 官方下载统计,@convex-dev/batched-replay 在 2026/10/11 – 2026/11/09 这 30 天内被下载 7,625 次,日均约 254 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@convex-dev/batched-replay 还在维护吗?

最新版本 0.1.1 发布于 2026/10/24(17 天前);至今共发布 3 个版本,其中过去 12 个月发布了 3 个;首次发布于 2026/10/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。