A collaborative sticky notes app built with svelte-realtime, svelte-adapter-uws, and svelte-adapter-uws-extensions.
Open the page, get a random name, drop notes on a shared canvas. Every note, cursor, and color change syncs across all browsers in real time. No login, no friction.
Try it now: svelte-realtime-demo.lantean.io -- open two tabs and watch the magic. Runs on a Hetzner CPX22 (2 shared vCPUs, 4 GB RAM, 6.49/month).
Source: github.com/lanteanio/svelte-realtime-demo
| Feature | Package | How it's used |
|---|---|---|
live() RPC |
svelte-realtime | Create, update, delete, and move notes |
live.stream() crud merge |
svelte-realtime | Notes on the canvas -- real-time CRUD |
live.stream() set merge |
svelte-realtime | Board settings (title, background color) |
live.stream() latest merge |
svelte-realtime | Activity ticker -- ephemeral ring buffer |
live.cron() |
svelte-realtime | Board cleanup -- delete stale boards every minute |
batch() |
svelte-realtime | Coalesce rapid note-drag moves into single WebSocket frames |
ctx.batch() |
svelte-realtime | Server-side batched publish for arrangement actions and cron cleanup |
| Optimistic updates | svelte-realtime | Note position updates instantly on drag, server confirms async |
| Undo / redo | svelte-realtime | Ctrl+Z / Ctrl+Shift+Z to undo note actions |
status store |
svelte-adapter-uws | Connection status dot in navbar (green/yellow/red) |
| Redis pub/sub bus | extensions | Multi-instance deployment with cross-instance updates |
| Input validation | server | Board titles, note content, colors, and coordinates are validated and bounded |
| Rate limiting | extensions | 100 RPCs per 10 seconds per user (drag/cursor moves are excluded) |
| Presence | extensions | Who's online globally and on each board, with heartbeat + maxAge cleanup |
| Cursors | extensions | Live cursor overlay with per-topic throttle (~60 broadcasts/sec) |
| Cursor snapshots | extensions | Joining users instantly see existing cursor positions |
| Circuit breaker | extensions | Redis failures degrade gracefully instead of blocking |
| Real-time unsubscribe | adapter 0.4.0 | Presence and cursors clean up immediately on page navigation |
| Canvas rendering | demo | 1000 cursors at 60fps via Canvas 2D with bitmap label caching |
| Batch SQL | demo | FAB actions (tidy, rearrange, shuffle, group) use a single query via unnest() |
| Board TTL | demo | Boards auto-delete after 1 hour of inactivity, with live countdown timer |
| Mobile support | demo | Touch dragging, responsive navbar, controls visible without hover |
Boards are ephemeral by design. Every board starts with a 1-hour countdown. Any meaningful action (create/edit/delete a note, change settings, run an arrangement) resets the timer. Boards with no activity for 1 hour are deleted automatically by a server-side cron job.
The stress-me-out board is exempt -- it's auto-created on startup and never expires. The E2E stress tests use it.
Countdown timers are visible on every board card (home page) and in the board header. They use the DaisyUI countdown component and change color as the deadline approaches: neutral > 10 min, warning 5-10 min, error < 5 min.
Stress-tested with 1000 simultaneous bot users on a single board, all moving cursors.
| Metric | Result |
|---|---|
| Connections | 1000/1000 (100%) |
| Connect time | ~8 seconds |
| FPS (1000 cursors) | 60 |
| p50 frame time | 16.7ms |
| p95 frame time | 18.0ms |
| JS heap | 9.5 MB |
| Server responsive | Yes |
Key optimizations:
drawImage() per framectx.batch() call instead of N+1 individual publishesunnest() query instead of N+1For OS-level tuning (sysctl, ulimits, conntrack), see the svelte-adapter-uws production docs.
npm install
Copy the example env file:
cp .env.example .env
The defaults point at localhost which works if Postgres and Redis are running in Docker on standard ports.
psql $DATABASE_URL -f schema.sql
The last_activity column is auto-migrated on startup if missing.
npm run dev
If DATABASE_URL is not set, the app falls back to an in-memory store. You can develop without Postgres or Redis -- all realtime features still work locally, just not across multiple server instances.
npm run build
npm start
The included docker-compose.yml sets up everything: app, Postgres, Redis, and a certbot container for automatic Let's Encrypt TLS. HTTPS out of the box, no reverse proxy.
The app runs as 2 independent replicas using network_mode: host and SO_REUSEPORT. The Linux kernel distributes incoming connections across both processes. Redis handles cross-process pub/sub.
.env file:DOMAIN=your-domain.com
POSTGRES_PASSWORD=pick-a-strong-password
docker compose run --rm certbot certonly --standalone -d your-domain.com
docker compose up -d
The app listens on port 443 directly (host networking). Certbot renews automatically every 12 hours. Postgres and Redis data are persisted in Docker volumes.
To scale replicas:
docker compose up -d --scale app=4
Playwright tests covering:
# Run everything
npm run test:e2e
# Run without the stress/destroyer tests (faster)
npx playwright test --grep-invert "Stress|Destroyer"
# Run only the stress test
npx playwright test e2e/stress.spec.js
# Run the destroyer from the server (bypass NAT limits)
node e2e/destroyer-standalone.js
node e2e/destroyer-standalone.js --cursors
Tests run against https://svelte-realtime-demo.lantean.io. Change baseURL in playwright.config.js to test elsewhere.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(none) | Postgres connection string. When unset, uses in-memory store. |
REDIS_URL |
redis://localhost:6379 |
Redis for pub/sub, presence, cursors, and rate limiting. |
HOST |
0.0.0.0 |
Server bind address. |
PORT |
3000 |
Server port. |
src/
├── hooks.ws.js -- WebSocket lifecycle (identity, presence, cursors)
├── hooks.server.js -- DB migration, stress board, error handler
├── app.html -- HTML shell with Svelte favicon
├── app.css -- Tailwind + DaisyUI setup
├── routes/
│ ├── +layout.svelte -- Navbar: identity, online count, colors, GitHub, theme
│ ├── +layout.server.js -- Identity cookie: read or generate
│ ├── +page.svelte -- Home: board list + create form + TTL hint
│ └── board/[slug]/
│ ├── +page.svelte -- Board: canvas, notes, FAB, undo/redo, rate limit toast
│ └── +page.server.js -- Resolve slug -> board_id
├── lib/
│ ├── names.js -- Random name/color/slug generator
│ ├── server/
│ │ ├── db.js -- Postgres + in-memory (touch, delete, stale cleanup)
│ │ ├── validate.js -- Input validation (UUID, bounds, allowlist)
│ │ └── redis.js -- Redis client, pub/sub, rate limiter, presence, cursors, breaker
│ └── components/
│ ├── StickyNote.svelte -- Draggable note: edit, color, delete, z-order, touch
│ ├── Canvas.svelte -- Board area: pointer tracking with rAF throttle
│ ├── CursorOverlay.svelte -- Canvas 2D cursor rendering with bitmap label cache
│ ├── PresenceBar.svelte -- Avatars with maxAge (8 desktop, 1 mobile, +N overflow)
│ ├── ActivityTicker.svelte -- Bottom bar: 5 most recent actions
│ ├── BoardHeader.svelte -- Title edit + background picker + TTL countdown
│ ├── BoardCard.svelte -- Board list item with presence badge + countdown
│ └── CountdownTimer.svelte -- DaisyUI countdown with color urgency
└── live/
├── boards.js -- Board CRUD + stream + cleanup cron (1h TTL)
└── boards/
├── notes.js -- Note CRUD + batch arrangements + board touch
├── activity.js -- Activity feed (ephemeral, latest merge)
├── settings.js -- Board settings (set merge)
└── cursors.js -- Presence join/leave + cursor movement
Two tables. No users, no sessions. Identity lives in a cookie. Activity is ephemeral.
CREATE TABLE board (
board_id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
title text NOT NULL,
slug text NOT NULL UNIQUE,
background text DEFAULT '#f5f5f4' NOT NULL,
last_activity timestamptz DEFAULT now() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE note (
note_id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
board_id uuid NOT NULL REFERENCES board (board_id) ON DELETE CASCADE,
content text DEFAULT '' NOT NULL,
x integer DEFAULT 200 NOT NULL,
y integer DEFAULT 200 NOT NULL,
color text DEFAULT '#fef08a' NOT NULL,
creator_name text NOT NULL,
z_index integer DEFAULT 0 NOT NULL,
is_archived boolean DEFAULT FALSE NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL
);
Full schema including indexes and auto-archive trigger in schema.sql.
No login. Every visitor gets a random two-word name (like "Cosmic Penguin") and a random color, stored in a cookie. Same tab, new tab, page reload -- same identity. New incognito window -- fresh identity.
The cookie is validated on both the WebSocket upgrade path (hooks.ws.js) and the HTTP layout load (+layout.server.js). Only cookies with a valid UUID, a name between 1-40 characters, and a valid hex color are accepted.
900 possible name combinations (30 adjectives x 30 nouns). Collisions are harmless -- names are for display only, the UUID is what matters.
svelte-adapter-uwsnotes, settings, activity) -- gets initial data + eventslive() RPC over WebSocketCursors bypass the database. Positions go through Redis pub/sub and are rendered on a Canvas 2D overlay.
Board cleanup runs as a live.cron() job every minute. It queries for boards where last_activity is older than 1 hour, deletes them, and publishes deleted events so all home page viewers see the board disappear.
MIT