Build Miro
KINDAreplaces $10/mosaves $120/yrback to the verdict
A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the relay and the build.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - have readyfree
Why Every collaboration claim in Phases 3 and 4 needs two participants. A single browser with two tabs is a weaker test but is the minimum.
Get it Two different browsers on one machine, or two machines on the same network. Two machines catch clock and latency issues that two tabs hide.
Verify
Two clients connect to the same board simultaneously - decidefree
Why It is the central architectural decision, and retrofitting it after building on last write wins means rewriting every mutation.
Get it Read the Yjs documentation on shared types before Phase 3, not during it. Half an hour saves a rewrite. open ↗
Verify
You can explain why two concurrent text edits merge rather than one winning - have readyfree
Why Phase 1's performance work is meaningless without it, and the naive approach feels perfect until this fixture exists.
Get it Write the generator in Phase 1 before optimising anything.
Verify
The generator produces a board that loads and renders - have ready5 to 10 USD a month
Why Only needed once other people use it. Everything before that runs locally.
Get it Any small virtual server with a domain, or a machine at home behind a tunnel.
Verify
The relay is reachable over TLS
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- The board is one Yjs document. These are the shared types inside it, not database tables. - objects · a shared map of id to object: kind, x, y, w, h, rotation, z, style and content - text · per-object shared text so two people editing the same sticky note merge rather than clobber - awareness · ephemeral cursor position, selection and name per participant, never persisted - Server side: boards(id, name, created_at), updates(board_id, seq, payload, at), snapshots(board_id, seq, state, at) - The rule that keeps multiplayer honest: every change goes through the shared document, including your own local edits. There is no separate local path.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
RELAY_URL | required | wss://boards.example.com | Where clients connect. Must be a secure WebSocket in anything but local development. |
PERSIST_DIR | required | ./data/boards | Where update logs and snapshots are written. Back up both together. |
SNAPSHOT_INTERVAL_UPDATES | required | 500 | How many updates before a snapshot is written. Lower means faster loads and more storage. |
CURSOR_THROTTLE_MS | required | 50 | Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. |
MAX_OBJECTS_PER_BOARD | required | 20000 | A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. |
The build, in order
A canvas that stays fast
Ten thousand objects pan and zoom smoothly because most of them are never drawn.
One transform from world coordinates to screen. Every coordinate conversion goes through it.
Files
src/viewport.tsterminalnpm create vite@latest . -- --template vanilla-ts
Insert object bounds, query by rectangle. It serves both viewport culling and hit testing.
Files
src/quadtree.tsA fixture board plus a frame time counter. Optimising against two hundred objects teaches you nothing.
Files
src/fixtures.ts
done when · tick each as it passeswatch out- Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine.
Objects and editing
Sticky notes, shapes, connectors and ink all behave, and text fits its note.
Sticky note, shape, connector, ink and text. Each is data plus a draw function.
Files
src/objects/Binary search the font size until the wrapped text fits, with a readable floor.
Files
src/text.tsA line simplification pass with a tolerance. Compare point counts before and after and look at the result.
Files
src/ink.ts
done when · tick each as it passeswatch out- Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail.
Real multiplayer
Two people editing at once both keep their work.
Objects as a shared map, sticky text as shared text. Local edits go through it exactly like remote ones.
Files
src/doc.tsterminalnpm install yjs y-websocket
Accept updates, persist them, rebroadcast. It stores and forwards; it never resolves conflicts.
Files
server/relay.tsDisconnect, edit both sides, reconnect. This is the case that separates real convergence from a demo.
Files
test/converge.test.ts
done when · tick each as it passeswatch out- Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation.
Presence and cursors
You can see where other people are and what they are working on.
Cursor, selection and name. Ephemeral by design, so it must never reach the persisted document.
Files
src/presence.tsA fixed rate that still reads as live. Measure the resulting message rate.
Files
src/presence.tsRemove participants on connection loss. A frozen cursor is worse than no cursor.
Files
server/relay.ts
done when · tick each as it passeswatch out- Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate.
Persistence and loading a big board
Opening a board with a long history is fast.
Write a snapshot periodically, then drop the updates it covers. Never drop updates a snapshot does not cover.
Files
server/persist.tsThe client receives one snapshot and the recent updates. Measure load time against a long history.
Files
server/load.tsRender the board region to an image, and serialise the document to JSON that re-imports.
Files
src/export.ts
done when · tick each as it passeswatch out- Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one.
Permissions, hosting and backupsproduct builder
Boards are private to the people you invited, and you can restore one.
Check the role on connection and reject updates from viewers server side.
Files
server/auth.tsConnect directly and attempt an update as a viewer. Testing through your own interface proves nothing.
Files
test/authz.test.tsUpdate log and snapshots together. Restore onto a clean server, open a board, record the date.
Files
scripts/backup.shREADME.md
done when · tick each as it passeswatch out- Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real.
That is the whole plan for Miro. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Performance at very large boards with many simultaneous editors.
- The template library.
- Integrations that embed boards in other tools.
- Video calls, timers, voting and the facilitation features.
- large-board performance
- templates
- integrations
- facilitation tools
- permissions
- enterprise controls
- Comments anchored to objects, which need their own resolution state
- Frames and simple presentation mode, which is cheap once the viewport is solid
- A read-only public share link rendered from a snapshot
Need the files? The project pack on the verdict page hands your agent the whole brief · more whiteboards.