Systems / Research Notes
LiveBoard: A Distributed Realtime Whiteboard With Durable Collaboration State
A full-stack collaborative canvas system with Redis-coordinated backend replicas, PostgreSQL-owned revisions, transient previews, shared undo/redo, access control, presence, and Drive-style organization.
Abstract
LiveBoard is a collaborative whiteboard application built around a React/SVG editor, horizontally scalable FastAPI backend replicas, PostgreSQL-owned durable state, Redis-coordinated ephemeral state, and WebSocket collaboration rooms. The application supports account sessions, owned and shared canvases, nested drag-and-drop folders, arbitrary sibling reordering, search and owner filtering for shared boards, invite and removal flows, realtime shape editing, presence cursors, infinite-canvas navigation, grouping, nested grouping, z-ordering, text editing with whole-object style and alignment controls, opacity, stroke width, rotation, multi-selection transforms, rate-limit recovery, and shared undo/redo. The central architectural choice is that durable canvas state, revision ordering, and undo/redo history remain owned by PostgreSQL and the backend, while Redis is used only for cross-server fanout, presence, invalidation, and counters. User actions are represented as typed operations, applied under an authoritative canvas revision, inverted against locked state, and broadcast to connected editors. This paper describes the product feature set, data model, operation algebra, collaboration protocol, distributed runtime, access lifecycle, frontend interaction model, and operational tradeoffs in sufficient detail to reconstruct the system.
I
System Object
LiveBoard is best understood as a product surface wrapped around an operation-processing core. The visible application has two major user spaces: the dashboard, where canvases and folders are managed, and the editor, where one canvas is edited collaboratively. The backend owns identities, sessions, access, canvas state, history, revision numbers, folder structure, and validation. Redis owns only ephemeral coordination for scaled backend replicas.
That division is the through-line for the system. The application has to feel immediate while people drag objects, move cursors, and change styles, but it also has to converge to one saved canvas after reconnects, rate limits, missed messages, and multiple backend servers. LiveBoard handles that by treating every user-visible edit as either a temporary preview or a durable operation. Temporary state can be fast and disposable; durable state has to pass through one ordered backend path.
The intended product setting is a focused working session: a small group of authenticated collaborators sketching diagrams, reviewing ideas, or arranging notes while talking elsewhere. LiveBoard optimizes for a shared editable canvas that can be reopened later, not for a public infinite whiteboard with anonymous links, asset uploads, export pipelines, or thousands of simultaneous cursors.
The important separation is durable versus transient state. PostgreSQL stores users, session tokens, canvases, folder rows, membership rows, operation logs, and undo/redo history. Redis stores cross-process Pub/Sub messages, presence TTL records, invalidation messages, and fixed-window rate-limit counters. React state stores the current view, selected ids, toolbar defaults, temporary drag and color previews, modal state, and local viewport.
That separation gives a simple rule for reading the rest of the paper: if losing the data would change the saved canvas, it belongs in PostgreSQL; if losing it would only make the live room briefly less smooth, it can live in Redis or browser memory. This rule explains why previews, cursors, and presence are treated differently from committed shape edits and history entries.
The runtime diagram is a failure map. If a browser refreshes, the canvas returns from PostgreSQL. If a Redis Pub/Sub event is missed, the next revision gap causes the client to refresh from PostgreSQL. If one backend replica dies, sockets reconnect through the proxy to another replica that shares the same database and Redis coordination layer. The fastest paths improve latency, but correctness falls back to durable state.
II
Dashboard and Folder Tree
The dashboard model intentionally resembles a small Drive-like file manager. Owned canvases and folders are siblings inside an implicit root or a parent folder. Shared canvases are not allowed to appear inside the owner's folder tree because folders are an owner-local organization primitive, not an access-control primitive.
- Owned canvases and folders render under Your canvases as one mixed, nested tree.
- Shared canvases render separately under Shared with You and can be searched by canvas name or owner username.
- Shared canvases can also be filtered by owner, which keeps several collaborators' boards from becoming one undifferentiated list.
- Folders can be created at the root, from empty list space, or from an existing folder row to create a nested folder.
- Folders and canvases can be selected together, including single-click, Ctrl/Cmd toggle selection, Shift range selection, and Ctrl/Cmd+A across the owned list.
- Owned canvases and folders support context-menu open, share, rename, create nested folder, delete, and destructive confirmation flows where applicable.
This distinction prevents the folder system from becoming a second permissions system. A folder answers the question, "Where did the owner put this canvas?" Membership answers the question, "Who can open this canvas?" Keeping those questions separate makes deletion, moving, sharing, and rendering easier to reason about. Deleting a folder can delete an owned subtree, while removing a member only removes an access edge.
type CanvasFolder = {
id: string;
name: string;
parentId?: string | null;
sortOrder: number;
updatedAt: string;
};
type CanvasSummary = {
id: string;
name: string;
ownerId: string;
folderId?: string | null;
sortOrder: number;
revision: number;
};Drag and drop is not just a visual affordance. Dropping an owned canvas onto the center of a folder row changes its parent folder through PATCH /api/canvases/{canvas_id}/folder. Dropping a folder onto another folder changes the folder's parent through PATCH /api/folders/{folder_id}/parent, with the backend rejecting moves into itself or one of its descendants. Dropping a canvas or folder into an insertion zone before, between, or after siblings sends the complete desired sibling order to PATCH /api/dashboard/order.
drag owned item
-> hover center of folder row
-> PATCH canvas folder or folder parent
-> backend verifies ownership and cycle rules
-> frontend refreshes owned tree placement
drag owned item
-> hover insertion zone before/between/after siblings
-> PATCH /api/dashboard/order with full mixed order
-> backend rewrites sort_order for that parent
-> frontend renders arbitrary sibling orderThe frontend computes tree rail segments statically from the sibling relationship at each level. A row receives one rail segment per indent: a straight rail when an ancestor has a following sibling, a T rail when the row itself has following siblings, and an L rail when it is the last child for its parent. This avoids CSS guessing and makes nested folder drawings deterministic from the tree data.
Deletion is also folder-aware. Deleting a canvas removes that canvas and cascades its membership, operations, and history rows. Deleting a folder removes the entire owned folder subtree, including nested folders and owned canvases inside it. The frontend routes destructive actions through the shared confirmation modal, and the backend closes live sockets for any deleted canvases so open editors do not keep editing a resource that no longer exists.
III
Canvas State and Shape Objects
A canvas stores a JSON state object with an optional background color and ordered shape list. Shape ordering is the z-order: later shapes draw above earlier shapes. Every durable shape mutation is expressed as create, update, delete, reorder, update_canvas, or batch. The backend validates the operation surface before any mutation is written or broadcast.
- Rectangles and ellipses store x/y/width/height, stroke, fill, opacity, stroke width, rotation, and grouping metadata.
- Lines store endpoint coordinates, stroke styling, opacity, stroke width, and grouping metadata; rotation is represented by endpoint updates rather than a separate angle.
- Text shapes store a rectangular text box, text content, text color, text opacity, font size, alignment, wrapping behavior, and ordinary shape styling.
- The canvas itself can store backgroundColor, which is updated by the paint bucket when the user clicks the background.
- The ordered shapes array doubles as z-order, so bring-forward/send-back operations are durable reorder_shape operations.
The shape model is intentionally plain. Instead of building a deep object hierarchy with separate tables for rectangles, ellipses, text, groups, and lines, the canvas state stores serializable shape records in one JSON document. The editor can evolve quickly and realtime messages stay small: most edits are just patches against a shape id. The tradeoff is that the backend must be strict about validating which fields are allowed for which shape type.
This is a document-model choice before it is a database-model choice. The canvas is edited as one ordered JSON document because the operations users perform are document operations: draw this shape, patch that shape, move this group, reorder this layer. Relational tables still own users, access, folders, and history, but the drawing itself remains a compact serializable object.
type BaseShape = {
id: string;
type: "rect" | "ellipse" | "line" | "text";
groupId?: string | null;
groupIds?: string[] | null;
strokeColor: string;
fillColor: string;
strokeOpacity: number;
fillOpacity: number;
strokeWidth: number;
rotation?: number;
createdBy: string;
updatedAt: number;
};
type RectLike = BaseShape & { x: number; y: number; width: number; height: number };
type LineShape = BaseShape & { x1: number; y1: number; x2: number; y2: number };
type TextShape = RectLike & {
text: string;
textColor: string;
textOpacity: number;
fontSize: number; // validated as 4..512
textAlign?: "left" | "center" | "right";
};Text is intentionally whole-object styling rather than per-span rich text. A text shape can edit content, text color, text opacity, font size, and left/center/right alignment. Older text shapes without textAlign render as left-aligned. The toolbar keeps text controls visible for discoverability, but disables and mutes them unless the selection contains only unlocked text shapes.
The text editing flow is designed around commit boundaries. Double-clicking or creating a text shape opens an inline textarea inside the SVG foreignObject. While the user types, text is local editor state. Blur, Escape, Tab, or Cmd/Ctrl+Enter commits one update_shape operation. Typing stays responsive, character-by-character collaboration stays out of scope, and undo/redo receives a clear unit: the completed text edit.
That is a product and architecture choice, not a missing parser detail. Per-span rich text would require text-range operations, selection ranges, conflict handling inside a string, and more complex undo semantics. LiveBoard's current text object behaves like a styled diagram label: the whole label can be aligned, resized, colored, moved, grouped, rotated, and undone as one canvas object.
Grouping is represented as a stack rather than as a separate group node. Each shape may carry groupIds, where the last element is the current active group. Creating a parent group appends a new id to every selected unit. Ungrouping removes only the active id, preserving child groups below it.
select units -> unit can be one unlocked shape or one already-grouped object -> Group appends new parent group id to every selected unit -> selection reconciles to the new top group -> move/scale/rotate operate on the group as one unit Ungroup -> remove only the active/top group id -> preserve child group ids underneath
The stack representation keeps grouping compatible with the rest of the operation model. Moving a group is still a batch of shape patches, deleting a group is still a batch of shape deletes, and undoing either action still uses the same inverse machinery as ordinary shape edits. There is no separate group object whose lifetime could drift away from its members.
IV
Operation Algebra and Shared History
The most important correctness decision in LiveBoard is moving undo/redo out of the client. If every editor kept a private history stack, two users editing the same object could undo different local stories over one shared state. LiveBoard instead treats history as a server-owned sequence of invertible operations serialized by the canvas row.
The key practical detail is that the server derives the inverse from the state it actually locked, not from the state a browser thought it had. Suppose one client changes a rectangle from blue to red while another client is slightly behind. The undo record must restore blue from the authoritative pre-operation state, not from a stale or optimistic client snapshot. This is why durable edits go through a transaction that reads the canvas row, validates the operation, computes the next state, and records both forward and inverse operations together.
- create_shape records newly drawn rectangles, ellipses, lines, and text boxes.
- update_shape records moves, resizes, rotations, text commits, style changes, grouping metadata changes, and endpoint updates for lines.
- update_canvas records canvas-level changes such as backgroundColor from the paint bucket.
- reorder_shape records bring-to-front, bring-forward, send-backward, and send-to-back context menu actions.
- delete_shape records toolbar delete, keyboard delete, and context-menu delete for unlocked shapes.
- batch records multi-shape gestures: multi-selection movement, group transforms, multi-style edits, grouping, ungrouping, and grouped deletes.
type CanvasOperation =
| { id: string; kind: "batch"; ops: CanvasOperation[] }
| { id: string; kind: "create_shape"; shape: Shape }
| { id: string; kind: "update_canvas"; patch: Partial<CanvasState> }
| { id: string; kind: "update_shape"; shapeId: string; patch: Partial<Shape> }
| { id: string; kind: "delete_shape"; shapeId: string }
| { id: string; kind: "reorder_shape"; shapeId: string; toIndex: number };Multi-shape movement, group transforms, folder-independent shape grouping, and shared style edits all become batch operations. The inverse of a batch is the reversed list of child inverses. That preserves atomic user intent: one drag of six objects creates one undo step rather than six unrelated updates. Operation ids also act as an idempotency surface: duplicate durable submissions can return the already recorded revision rather than applying the same mutation twice.
Batches also make the UI feel honest. If a user rotates a selected cluster, the system should not expose the implementation detail that five individual shape records changed. The history entry should say, in effect, "undo that rotation." The server still stores the precise patches needed to reverse the change, but the user-facing unit remains the gesture that created it.
draw shape -> create_shape drag one shape -> preview_op update_shape, then durable update_shape drag group or selection -> preview_op batch, then durable batch edit text content -> local textarea draft, then durable update_shape change style -> update_shape or batch paint background -> update_canvas bring forward/back -> reorder_shape group/ungroup -> batch of groupIds patches undo/redo -> server applies stored inverse/forward op
BEGIN;
SELECT state, revision FROM canvases WHERE id = $1 FOR UPDATE;
-- validate operation and derive inverse from locked state
UPDATE canvases SET state = $next_state, revision = revision + 1 WHERE id = $1;
INSERT INTO canvas_ops(canvas_id, revision, op) VALUES ($1, $next_revision, $op);
INSERT INTO canvas_history(canvas_id, forward_op, inverse_op, applied_revision)
VALUES ($1, $op, $inverse, $next_revision);
COMMIT;V
Realtime Protocol
WebSocket collaboration separates previews from durable commits. During drag, resize, and rotation, the frontend sends throttled preview operations so other users see motion without inflating the revision counter. Color picker movement is local-only preview state until the picker rests or blurs, so dragging through a spectrum does not create hundreds of saved revisions. On pointer up, blur, Enter, or another commit boundary, the frontend sends one durable operation with history enabled.
Opening a canvas uses both HTTP and WebSocket paths. The whiteboard first fetches canvas metadata and the current durable state through GET /api/canvases/{canvas_id}. It then opens /ws/canvases/{canvas_id}, authenticated by the same httpOnly session cookie. The socket returns a snapshot containing state, revision, active users, and undo/redo availability. From that point forward, ordinary resource management remains HTTP, while live editing, cursors, previews, undo, and redo travel over the socket.
This preview/commit split is what lets LiveBoard feel live without turning every pixel of movement into saved history. Other clients can see an object moving during a drag, but the durable record only contains the final resting position. The same idea applies to color selection: intermediate swatch values help the local user preview the result, while the saved canvas receives one final color change.
- cursor messages keep collaborators visible without changing canvas state.
- preview_op messages show remote drag, resize, and rotation motion without incrementing revision.
- op messages represent committed changes that must be validated, persisted, inverted, and broadcast with a new revision.
- undo and redo messages ask the server to apply shared history, so every connected editor sees the same result.
- presence_join and presence_leave messages maintain the active collaborator list.
- access_removed, session_expired, rate_limited, and preview_reset messages are recovery/control events that keep users and canvases in a correct state.
client A pointer move -> preview_op(batch update_shape...) -> room fanout -> client B applies transient preview client A pointer up -> op(batch update_shape..., undoable=true) -> backend validates + locks + persists -> op_applied(revision+1, history_status) -> all clients reconcile state
client current revision = 17 receives op_applied revision = 19 -> expected 18, observed 19 -> pause operation application -> GET /api/canvases/:canvasId -> replace local canvas state, revision, history -> resume from durable PostgreSQL snapshot
Revision-gap recovery is deliberately simple. The client does not try to replay a partial event stream or ask Redis for missed messages. It notices that the next durable revision is not the one it expected and replaces local canvas state with the authoritative HTTP snapshot. Pub/Sub can stay ephemeral because PostgreSQL remains the replay source for durable state.
Presence is a separate stream: cursor messages carry canvas-space coordinates, selected shape id, user id, username, and a deterministic user color. Remote cursors are inverse-scaled by the local zoom so the cursor glyph stays the same screen size as each editor pans or zooms.
Local optimism is intentionally bounded. A sender applies its own durable operation immediately so the UI does not wait on the network, but useCanvasSocket tracks operation ids so the sender does not double-apply the broadcast echo. If the server sends a snapshot, rate-limit event, or revision-gap refresh, the optimistic queue is cleared and local canvas state is replaced with PostgreSQL truth.
VI
Distributed Runtime
LiveBoard can run several FastAPI backend containers behind a backend proxy. This required separating local socket ownership from room-wide collaboration semantics. Each replica owns only the WebSocket objects connected to that process. Redis carries room events, presence records, invalidation messages, and rate-limit counters across replicas.
Without Redis, a single process can broadcast to the sockets it owns, but two backend processes would become two isolated rooms. A user connected to replica A would not automatically reach a user connected to replica B. Redis closes that gap by acting as the shared notification layer between replicas while leaving each process responsible for its own in-memory WebSocket objects.
liveboard:canvas:{canvas_id}:events
liveboard:presence:{canvas_id}:connections
liveboard:presence:conn:{connection_id}
rate:{scope}:{bucket}Presence is user-level even when the same user has multiple tabs open. Redis stores connection ids with short TTLs. A join is broadcast when a user moves from zero connections to one or more, and a leave is delayed briefly so a refresh or tab handoff does not flicker the collaborator list.
The TTL detail is important in a distributed WebSocket system. If a backend process exits without running its disconnect cleanup, Redis will eventually expire its connection records. That means the presence layer is eventually self-healing instead of depending on perfect shutdown behavior. The short delayed leave avoids the opposite problem: making ordinary reconnects look like people rapidly leaving and rejoining.
socket accepted -> add connection id to canvas set -> write connection record with TTL -> if previous user connection count was 0: presence_join socket closed -> remove connection id -> wait briefly -> if user connection count is still 0: presence_leave
Rate limits are shared across replicas when Redis is enabled. Authentication routes, HTTP API routes, cursor messages, preview messages, history messages, and durable writes each have separate counters. The write limit is the most important collaboration guard because it protects revision churn and history growth. Cursor and preview limits are intentionally higher so normal collaboration remains fluid.
The rate-limit behavior is tied back into collaboration recovery. When a durable write is rejected, the server does not let the rejected operation leak to peers as if it had succeeded. The writer receives the saved canvas snapshot and briefly stops interacting with the canvas while it syncs. Peers receive a preview reset so any transient movement they saw from that writer is discarded. The failure mode is visible instead of being hidden behind a best-effort patch over a rejected write.
docker compose up --build --scale server=3
curl http://localhost:3001/health
# {"ok": true, "postgres": true, "redis": true}The single-process development path still works when REDIS_URL is unset: fanout, presence, and rate limits fall back to in-memory process state. That fallback supports local backend work, but it is intentionally not treated as a distributed mode.
VII
Editor Geometry and Interaction Model
The editor uses SVG as the scene representation. The canvas viewport is effectively infinite by rendering a very large background rectangle and treating the SVG viewBox as the camera window. Wheel input changes zoom around the cursor. Middle-button or background drag changes the viewport origin.
- Toolbar tools include select, rectangle, ellipse, line, text, and paint bucket.
- The header shows canvas metadata, revision state, active presence, navigation, and sharing controls.
- Mouse wheel zooms around the pointer, and middle-button dragging pans without touching shared canvas state.
- Select-tool background drag draws a box selection; dragging selected artwork moves selected units.
- Delete/Backspace removes selected unlocked shapes; Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, and Cmd/Ctrl+Y call server undo/redo.
- Right-click shape menus expose z-order actions, group/ungroup, and delete.
- The paint bucket can apply fill style to a shape or update the canvas background color.
Using SVG keeps the editor close to the document model. Rectangles, ellipses, lines, text, selection outlines, and handles are all inspectable vector elements rather than pixels in a canvas bitmap. The cost is that geometry has to be explicit: zoom, rotation, hit targets, transformed bounds, and text editing all need careful coordinate conversion.
Selection behavior distinguishes object editing from viewport movement. Left-dragging the background with the select tool draws a box selection. Dragging any selected object moves the whole selection. Combined selection handles scale selected units, and the rotation handle rotates every selected unit around the selection center.
idle -> background pointer down with select tool: box-selecting -> drawing tool pointer down: drawing draft shape -> selected shape pointer down: moving -> resize handle pointer down: resizing -> rotation handle pointer down: rotating -> middle button down: panning local viewport pointer up -> local preview becomes one durable operation when the gesture changed shared canvas state
The interaction model tries to make the same gesture mean the same thing for one object, many objects, and a grouped unit. A drag moves the selected unit, a corner handle scales it, and the rotation handle rotates it. The implementation may produce one shape patch or a batch of many shape patches, but the user's mental model stays centered on the visible selection box.
Selection overlays are not the stored axis-aligned boxes. Single rotated shapes compute rendered corners from the stored rotation so the outline and handles remain aligned with the visual object. Multi-selection and group overlays compute the axis-aligned bounds of each member's rendered corners, producing a combined box that wraps what the user actually sees.
Toolbar synchronization follows the same respect for selection state. When every selected relevant shape shares a value, that value appears in the toolbar. If selected shapes disagree, the control remains useful as a next action but does not pretend there is one current value. Text controls stay visible so users can discover them, but they are muted and disabled unless the selection contains only unlocked text shapes.
- Stroke controls: color, opacity, width.
- Fill controls: color and opacity.
- Text controls: text color, text opacity, pixel font size with increase/decrease buttons, and left/center/right alignment.
- Slider controls commit on release rather than on every movement.
- Color controls preview locally while the picker is moving and commit once the color rests or the picker blurs.
- Grouped members are locked from direct style, text, bucket, and delete mutations, while the group can still move, scale, rotate, or be nested into a parent group.
The canvas is visually infinite and operationally finite only in the mathematical sense that coordinates must be valid finite numbers. The backend does not validate movement against the initial viewport dimensions, so dragging far left, right, up, or down can still produce a durable operation that every client will converge on.
Read-only canvas text is not browser-selectable, which keeps object drags from turning into accidental text selection. The one exception is the active inline text editor: while editing a text shape, that shape's editor allows normal browser text selection, caret movement, and typing.
VIII
Product and Architecture Tradeoffs
LiveBoard deliberately avoids several features that would change the collaboration model. Character-by-character text editing would require string-range operations and conflict handling inside one text object. Share links and role tiers would expand the access model beyond owner-managed membership. Image uploads and export would introduce storage, rendering, and security surfaces that are orthogonal to the core realtime operation engine.
The central architecture tradeoff is using server-serialized operations rather than a fully peer-to-peer or CRDT-first editor. Offline merging becomes less ambitious, but the system gains one authoritative revision order, one shared undo/redo history, and straightforward recovery after missed Pub/Sub messages. For small synchronous sessions, that exchange is worth taking.
- PostgreSQL authority simplifies recovery and history, but every durable edit must pass through the backend.
- Transient previews keep dragging fluid, but they must be discarded on rejected writes or revision refreshes.
- JSON canvas state is easy to patch and broadcast, but validation must enforce shape-specific rules.
- Whole-object text editing keeps collaboration simple, but it does not support rich text ranges.
IX
Access Control, Sessions, and Removal
Canvas access is a graph edge from user to canvas, plus ownership. The owner is implicit from the canvas row. Shared access is stored in a membership table. A user may open a canvas if they are the owner or a current member.
The application bootstrap starts with the same security model. On load, the React app calls GET /api/me with same-origin credentials. If the liveboard_session cookie maps to a valid server-side session, the dashboard opens. If not, the user sees signup/login. Signup normalizes credentials, hashes the password, creates a session, and sets the httpOnly cookie. Login accepts username or email, verifies the stored password hash, and creates the same session shape.
Access control is enforced on both ordinary HTTP routes and the WebSocket path. That is necessary because a collaborative editor has two doors into the same resource: a user can fetch the canvas over HTTP, or they can join the live editing room. The same predicate has to guard both doors, and it has to remain true after the socket has already opened.
Session tokens are stored server-side, expire automatically, and are rechecked while WebSockets are open. Deleting a stolen token eventually stops an attacker even if they already established a socket. Membership removal also closes active sockets for the removed user and displays an access message on their screen.
owner opens share modal -> GET /api/canvases/:id/members -> owner enters username or email -> POST /api/canvases/:id/invite -> backend verifies owner and target user -> INSERT canvas_members -> modal updates member list -> invited user sees canvas in Shared with You
owner removes member -> DELETE /api/canvases/:id/members/:userId -> database membership row deleted -> Redis invalidation reaches every backend replica -> each room manager finds matching local sockets -> access_removed message -> socket close -> removed client stops receiving updates
The Redis invalidation path is an acceleration path, not the security boundary. Open sockets re-check their session and canvas membership before every incoming message and every 30 seconds while idle. If an invalidation message is missed, the next database-backed check still closes an unauthorized socket.
Canvas rename is intentionally split by ownership and context. Owners can rename from the dashboard context menu or inline from the board header; both use PATCH /api/canvases/{canvas_id}. Connected editors receive a canvas_renamed WebSocket event so the open header stays in sync. Non-owners can open shared canvases but cannot rename them or move them through the owner's folder tree.
X
Durable Data Layout
The database layout is small but deliberately complete. Users and sessions support authentication. Canvases own JSON state and revision. Members represent sharing. Folders organize only the owner's own canvases. Operations form an append-only record of durable mutations, and history rows point to forward/inverse operations for undo and redo.
There are two different storage shapes in play. Relational tables model identity, ownership, membership, folders, and append-only operation metadata because those are relationships the database is good at enforcing. The canvas drawing itself is stored as JSON because the shape schema is polymorphic and evolves with editor features. The operation validator is the boundary that keeps the flexible JSON state from becoming arbitrary untrusted data.
users id, username, email, password_hash sessions token_hash, user_id, expires_at canvases id, owner_id, name, folder_id, sort_order, state, revision canvas_members canvas_id, user_id canvas_folders id, owner_id, parent_id, name, sort_order canvas_ops id, canvas_id, revision, op canvas_history id, canvas_id, user_id, forward_op, inverse_op, undone_at
The state column is JSON because shape variants evolve quickly and because operations already validate the patch surface. Relational rows are used where relationships matter: users, sessions, members, folders, operation metadata, and history entries.
The revision column is the bridge between those two worlds. It is stored with the canvas row, advanced under the same lock that writes JSON state, and echoed to clients in every durable WebSocket message. That single number lets clients detect missed operations, lets the UI display saved progress, and gives distributed fanout a convergence check without requiring sticky sessions or durable Redis streams.
PostgreSQL users, sessions, canvases, folders, members canvas state snapshots revision numbers operation log undo/redo history Redis Pub/Sub fanout presence TTLs rate-limit counters access/deletion invalidation Browser viewport selection toolbar draft values local color and drag previews
XI
Feature Flow Catalog
A LiveBoard feature belongs in the architecture when it has a clear route through the system: one obvious frontend owner, one obvious backend contract, one durable state owner when persistence is needed, and one realtime story when collaborators are affected. The following catalog summarizes those routes.
signup/login
AuthScreen -> /api/auth/signup or /api/auth/login -> sessions -> dashboard
create canvas
Dashboard -> POST /api/canvases -> canvases + owner membership -> rename modal
create nested folder
Dashboard/FolderModal -> POST /api/folders(parentId) -> canvas_folders
move canvas into folder
drag/drop -> PATCH /api/canvases/:id/folder -> canvases.folder_id
move folder into folder
drag/drop -> PATCH /api/folders/:id/parent -> canvas_folders.parent_id
reorder mixed siblings
insertion zone drop -> PATCH /api/dashboard/order -> sort_order rewrite
share canvas
ShareModal -> POST /api/canvases/:id/invite -> canvas_members
delete folder subtree
ConfirmModal -> DELETE /api/folders/:id -> folders, canvases, sockets closeddraw
pointer draft -> create_shape -> PostgreSQL revision -> op_applied
move/resize/rotate
local optimistic preview -> preview_op fanout -> pointer-up durable op
multi-select or group transform
combined bounds -> batch preview -> batch durable op -> one undo step
style change
toolbar control -> update_shape or batch -> server-derived inverse
text edit
inline textarea draft -> update_shape on commit -> one history entry
paint bucket
shape click -> update_shape fill patch
background click -> update_canvas backgroundColor
z-order action
context menu -> reorder_shape -> shapes array order changes
undo/redo
toolbar/shortcut -> websocket undo/redo -> server history -> op_appliedcursor movement
canvas coordinates -> cursor message -> Redis fanout -> inverse-scaled remote cursor
durable op miss
client observes revision gap -> GET /api/canvases/:id -> replace local state
rate-limited write
backend rejects before persistence -> rate_limited snapshot to sender
peers receive preview_reset -> refresh durable state
access removal
DELETE member row -> Redis invalidation -> access_removed -> socket close
server scale-out
any replica handles HTTP/WS -> PostgreSQL serializes writes
Redis fans out transient and committed room eventsXII
Design Choices and Tradeoffs
- Shared undo/redo belongs near the authoritative state, not in each browser tab.
- Preview operations should use the same data shape as durable operations but must not increment revision.
- Redis is a coordination layer, not a canvas-state cache; that keeps failure recovery tied to PostgreSQL snapshots and revisions.
- Revision-gap recovery is cheaper than making every realtime event durable because cursor, preview, and presence messages are intentionally ephemeral.
- Fixed-window rate limits are simple and predictable; the tradeoff is bucket-edge burstiness, which is acceptable for this collaboration workload.
- Folders should organize owner-owned canvases only; sharing is a separate access graph.
- Selection reconciliation must run after local, remote, undo, and redo operations so grouped children cannot remain individually editable.
- SVG is a practical editor substrate when geometry helpers, rendered bounds, and viewport math are kept explicit.
- Access removal must affect live sockets, not just future HTTP requests.
- Whole-object text styling keeps collaboration and undo semantics tractable; per-span rich text would require a deeper text operation model.
sticky sessions only -> rejected: correctness should not depend on load-balancer affinity PostgreSQL LISTEN/NOTIFY for everything -> rejected: high-frequency preview/cursor traffic should not live in the durable database path Redis Streams for all collaboration events -> rejected: durable operations are already recoverable by revision snapshot Redis canvas-state cache -> rejected: adds invalidation around the most important state
The alternatives mostly fail by putting the wrong kind of state in the wrong layer. Sticky sessions make the load balancer part of the correctness story. PostgreSQL notifications make transient cursor traffic compete with durable database work. Redis Streams add replay machinery for events that either do not need replay or can already be recovered from PostgreSQL. A Redis canvas cache would speed up some reads but complicate the only state that must never be ambiguous.
The project is less about one isolated trick than about making many small contracts agree: JSON shape state, relational ownership, WebSocket rooms, Redis coordination, React interaction state, server-side history, and a serious dashboard UI. LiveBoard works because each boundary is narrow enough to explain and direct enough to test, while the complete product still feels like one continuous collaborative surface.