diff --git a/.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch b/.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch
deleted file mode 100644
index aac702d36..000000000
--- a/.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch
+++ /dev/null
@@ -1,26 +0,0 @@
-diff --git a/package.json b/package.json
-index 5f953f00544710a638dc502b30841d39193f6d3f..6c31784d1b1f32ee8f21106011c4e6ef526f1560 100644
---- a/package.json
-+++ b/package.json
-@@ -47,18 +47,21 @@
-     "./sync.js": "./sync.js",
-     "./dist/sync.cjs": "./dist/sync.cjs",
-     "./sync": {
-+      "types": "./sync.d.ts",
-       "import": "./sync.js",
-       "require": "./dist/sync.cjs"
-     },
-     "./awareness.js": "./awareness.js",
-     "./dist/awareness.cjs": "./dist/awareness.cjs",
-     "./awareness": {
-+      "types": "./awareness.d.ts",
-       "import": "./awareness.js",
-       "require": "./dist/awareness.cjs"
-     },
-     "./auth.js": "./auth.js",
-     "./dist/auth.cjs": "./dist/auth.cjs",
-     "./auth": {
-+      "types": "./auth.d.ts",
-       "import": "./auth.js",
-       "require": "./dist/auth.cjs"
-     }
diff --git a/backend/package.json b/backend/package.json
index b9900d256..dfd5e51b0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -58,7 +58,6 @@
     "file-type": "16.5.4",
     "joi": "17.9.1",
     "ldapauth-fork": "5.0.5",
-    "lib0": "0.2.73",
     "minio": "7.0.33",
     "mysql": "2.18.1",
     "nest-router": "1.0.9",
@@ -75,7 +74,6 @@
     "sqlite3": "5.1.6",
     "typeorm": "0.3.7",
     "ws": "8.13.0",
-    "y-protocols": "1.0.5",
     "yjs": "13.5.51"
   },
   "devDependencies": {
diff --git a/backend/src/notes/notes.service.ts b/backend/src/notes/notes.service.ts
index de8e366f9..c1febee44 100644
--- a/backend/src/notes/notes.service.ts
+++ b/backend/src/notes/notes.service.ts
@@ -182,7 +182,10 @@ export class NotesService {
    */
   async getNoteContent(note: Note): Promise<string> {
     return (
-      this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ??
+      this.realtimeNoteStore
+        .find(note.id)
+        ?.getRealtimeDoc()
+        .getCurrentContent() ??
       (await this.revisionsService.getLatestRevision(note)).content
     );
   }
diff --git a/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts
new file mode 100644
index 000000000..eccef8dbc
--- /dev/null
+++ b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { generateRandomName } from './name-randomizer';
+
+describe('name randomizer', () => {
+  it('generates random names', () => {
+    const firstName = generateRandomName();
+    const secondName = generateRandomName();
+    expect(firstName).not.toBe('');
+    expect(firstName).not.toBe(secondName);
+  });
+});
diff --git a/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts
new file mode 100644
index 000000000..bb6347e97
--- /dev/null
+++ b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import lists from './random-words.json';
+
+/**
+ * Generates a random names based on an adjective and a noun.
+ *
+ * @return the generated name
+ */
+export function generateRandomName(): string {
+  const adjective = generateRandomWord(lists.adjectives);
+  const things = generateRandomWord(lists.items);
+  return `${adjective} ${things}`;
+}
+
+function generateRandomWord(list: string[]): string {
+  const index = Math.floor(Math.random() * list.length);
+  const word = list[index];
+  return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase();
+}
diff --git a/backend/src/realtime/realtime-note/random-word-lists/random-words.json b/backend/src/realtime/realtime-note/random-word-lists/random-words.json
new file mode 100644
index 000000000..433973b81
--- /dev/null
+++ b/backend/src/realtime/realtime-note/random-word-lists/random-words.json
@@ -0,0 +1,1329 @@
+{
+  "items": [
+    "apple",
+    "bag",
+    "balloon",
+    "bananas",
+    "bed",
+    "beef",
+    "blouse",
+    "book",
+    "bookmark",
+    "boom box",
+    "bottle",
+    "bottle cap",
+    "bow",
+    "bowl",
+    "box",
+    "bracelet",
+    "bread",
+    "brocolli",
+    "hair brush",
+    "buckle",
+    "button",
+    "camera",
+    "candle",
+    "candy wrapper",
+    "canvas",
+    "car",
+    "greeting card",
+    "playing card",
+    "carrots",
+    "cat",
+    "CD",
+    "cell phone",
+    "packing peanuts",
+    "cinder block",
+    "chair",
+    "chalk",
+    "newspaper",
+    "soy sauce packet",
+    "chapter book",
+    "checkbook",
+    "chocolate",
+    "clay pot",
+    "clock",
+    "clothes",
+    "computer",
+    "conditioner",
+    "cookie jar",
+    "cork",
+    "couch",
+    "credit card",
+    "cup",
+    "deodorant ",
+    "desk",
+    "door",
+    "drawer",
+    "drill press",
+    "eraser",
+    "eye liner",
+    "face wash",
+    "fake flowers",
+    "flag",
+    "floor",
+    "flowers",
+    "food",
+    "fork",
+    "fridge",
+    "glass",
+    "glasses",
+    "glow stick",
+    "grid paper",
+    "hair tie",
+    "hanger",
+    "helmet",
+    "house",
+    "ipod",
+    "charger",
+    "key chain",
+    "keyboard",
+    "keys",
+    "knife",
+    "lace",
+    "lamp",
+    "lamp shade",
+    "leg warmers",
+    "lip gloss",
+    "lotion",
+    "milk",
+    "mirror",
+    "model car",
+    "money",
+    "monitor",
+    "mop",
+    "mouse pad",
+    "mp3 player",
+    "nail clippers",
+    "nail file",
+    "needle",
+    "outlet",
+    "paint brush",
+    "pants",
+    "paper",
+    "pen",
+    "pencil",
+    "perfume",
+    "phone",
+    "photo album",
+    "picture frame",
+    "pillow",
+    "plastic fork",
+    "plate",
+    "pool stick",
+    "soda can",
+    "puddle",
+    "purse",
+    "blanket",
+    "radio",
+    "remote",
+    "ring",
+    "rubber band",
+    "rubber duck",
+    "rug",
+    "rusty nail",
+    "sailboat",
+    "sand paper",
+    "sandal",
+    "scotch tape",
+    "screw",
+    "seat belt",
+    "shampoo",
+    "sharpie",
+    "shawl",
+    "shirt",
+    "shoe lace",
+    "shoes",
+    "shovel",
+    "sidewalk",
+    "sketch pad",
+    "slipper",
+    "soap",
+    "socks",
+    "sofa",
+    "speakers",
+    "sponge",
+    "spoon",
+    "spring",
+    "sticky note",
+    "stockings",
+    "stop sign",
+    "street lights",
+    "sun glasses",
+    "table",
+    "teddies",
+    "television",
+    "thermometer",
+    "thread",
+    "tire swing",
+    "tissue box",
+    "toe ring",
+    "toilet",
+    "tomato",
+    "tooth picks",
+    "toothbrush",
+    "toothpaste",
+    "towel",
+    "tree",
+    "truck",
+    "tv",
+    "twezzers",
+    "twister",
+    "vase",
+    "video games",
+    "wallet",
+    "washing machine",
+    "watch",
+    "water bottle",
+    "doll",
+    "magnet",
+    "wagon",
+    "headphones",
+    "clamp",
+    "USB drive",
+    "air freshener",
+    "piano",
+    "ice cube tray",
+    "white out",
+    "window",
+    "controller",
+    "coasters",
+    "thermostat",
+    "zipper"
+  ],
+  "adjectives": [
+    "aback",
+    "abaft",
+    "abandoned",
+    "abashed",
+    "aberrant",
+    "abhorrent",
+    "abiding",
+    "abject",
+    "ablaze",
+    "able",
+    "abnormal",
+    "aboard",
+    "aboriginal",
+    "abortive",
+    "abounding",
+    "abrasive",
+    "abrupt",
+    "absent",
+    "absorbed",
+    "absorbing",
+    "abstracted",
+    "absurd",
+    "abundant",
+    "abusive",
+    "acceptable",
+    "accessible",
+    "accidental",
+    "accurate",
+    "acid",
+    "acidic",
+    "acoustic",
+    "acrid",
+    "actually",
+    "ad hoc",
+    "adamant",
+    "adaptable",
+    "addicted",
+    "adhesive",
+    "adjoining",
+    "adorable",
+    "adventurous",
+    "afraid",
+    "aggressive",
+    "agonizing",
+    "agreeable",
+    "ahead",
+    "ajar",
+    "alcoholic",
+    "alert",
+    "alike",
+    "alive",
+    "alleged",
+    "alluring",
+    "aloof",
+    "amazing",
+    "ambiguous",
+    "ambitious",
+    "amuck",
+    "amused",
+    "amusing",
+    "ancient",
+    "angry",
+    "animated",
+    "annoyed",
+    "annoying",
+    "anxious",
+    "apathetic",
+    "aquatic",
+    "aromatic",
+    "arrogant",
+    "ashamed",
+    "aspiring",
+    "assorted",
+    "astonishing",
+    "attractive",
+    "auspicious",
+    "automatic",
+    "available",
+    "average",
+    "awake",
+    "aware",
+    "awesome",
+    "awful",
+    "axiomatic",
+    "bad",
+    "barbarous",
+    "bashful",
+    "bawdy",
+    "beautiful",
+    "befitting",
+    "belligerent",
+    "beneficial",
+    "bent",
+    "berserk",
+    "best",
+    "better",
+    "bewildered",
+    "big",
+    "billowy",
+    "bite-sized",
+    "bitter",
+    "bizarre",
+    "black",
+    "black-and-white",
+    "bloody",
+    "blue",
+    "blue-eyed",
+    "blushing",
+    "boiling",
+    "boorish",
+    "bored",
+    "boring",
+    "bouncy",
+    "boundless",
+    "brainy",
+    "brash",
+    "brave",
+    "brawny",
+    "breakable",
+    "breezy",
+    "brief",
+    "bright",
+    "bright",
+    "broad",
+    "broken",
+    "brown",
+    "bumpy",
+    "burly",
+    "bustling",
+    "busy",
+    "cagey",
+    "calculating",
+    "callous",
+    "calm",
+    "capable",
+    "capricious",
+    "careful",
+    "careless",
+    "caring",
+    "cautious",
+    "ceaseless",
+    "certain",
+    "changeable",
+    "charming",
+    "cheap",
+    "cheerful",
+    "chemical",
+    "chief",
+    "childlike",
+    "chilly",
+    "chivalrous",
+    "chubby",
+    "chunky",
+    "clammy",
+    "classy",
+    "clean",
+    "clear",
+    "clever",
+    "cloistered",
+    "cloudy",
+    "closed",
+    "clumsy",
+    "cluttered",
+    "coherent",
+    "cold",
+    "colorful",
+    "colossal",
+    "combative",
+    "comfortable",
+    "common",
+    "complete",
+    "complex",
+    "concerned",
+    "condemned",
+    "confused",
+    "conscious",
+    "cooing",
+    "cool",
+    "cooperative",
+    "coordinated",
+    "courageous",
+    "cowardly",
+    "crabby",
+    "craven",
+    "crazy",
+    "creepy",
+    "crooked",
+    "crowded",
+    "cruel",
+    "cuddly",
+    "cultured",
+    "cumbersome",
+    "curious",
+    "curly",
+    "curved",
+    "curvy",
+    "cut",
+    "cute",
+    "cute",
+    "cynical",
+    "daffy",
+    "daily",
+    "damaged",
+    "damaging",
+    "damp",
+    "dangerous",
+    "dapper",
+    "dark",
+    "dashing",
+    "dazzling",
+    "dead",
+    "deadpan",
+    "deafening",
+    "dear",
+    "debonair",
+    "decisive",
+    "decorous",
+    "deep",
+    "deeply",
+    "defeated",
+    "defective",
+    "defiant",
+    "delicate",
+    "delicious",
+    "delightful",
+    "demonic",
+    "delirious",
+    "dependent",
+    "depressed",
+    "deranged",
+    "descriptive",
+    "deserted",
+    "detailed",
+    "determined",
+    "devilish",
+    "didactic",
+    "different",
+    "difficult",
+    "diligent",
+    "direful",
+    "dirty",
+    "disagreeable",
+    "disastrous",
+    "discreet",
+    "disgusted",
+    "disgusting",
+    "disillusioned",
+    "dispensable",
+    "distinct",
+    "disturbed",
+    "divergent",
+    "dizzy",
+    "domineering",
+    "doubtful",
+    "drab",
+    "draconian",
+    "dramatic",
+    "dreary",
+    "drunk",
+    "dry",
+    "dull",
+    "dusty",
+    "dusty",
+    "dynamic",
+    "dysfunctional",
+    "eager",
+    "early",
+    "earsplitting",
+    "earthy",
+    "easy",
+    "eatable",
+    "economic",
+    "educated",
+    "efficacious",
+    "efficient",
+    "eight",
+    "elastic",
+    "elated",
+    "elderly",
+    "electric",
+    "elegant",
+    "elfin",
+    "elite",
+    "embarrassed",
+    "eminent",
+    "empty",
+    "enchanted",
+    "enchanting",
+    "encouraging",
+    "endurable",
+    "energetic",
+    "enormous",
+    "entertaining",
+    "enthusiastic",
+    "envious",
+    "equable",
+    "equal",
+    "erect",
+    "erratic",
+    "ethereal",
+    "evanescent",
+    "evasive",
+    "even",
+    "excellent",
+    "excited",
+    "exciting",
+    "exclusive",
+    "exotic",
+    "expensive",
+    "extra-large",
+    "extra-small",
+    "exuberant",
+    "exultant",
+    "fabulous",
+    "faded",
+    "faint",
+    "fair",
+    "faithful",
+    "fallacious",
+    "false",
+    "familiar",
+    "famous",
+    "fanatical",
+    "fancy",
+    "fantastic",
+    "far",
+    "far-flung",
+    "fascinated",
+    "fast",
+    "fat",
+    "faulty",
+    "fearful",
+    "fearless",
+    "feeble",
+    "feigned",
+    "female",
+    "fertile",
+    "festive",
+    "few",
+    "fierce",
+    "filthy",
+    "fine",
+    "finicky",
+    "first",
+    "five",
+    "fixed",
+    "flagrant",
+    "flaky",
+    "flashy",
+    "flat",
+    "flawless",
+    "flimsy",
+    "flippant",
+    "flowery",
+    "fluffy",
+    "fluttering",
+    "foamy",
+    "foolish",
+    "foregoing",
+    "forgetful",
+    "fortunate",
+    "four",
+    "frail",
+    "fragile",
+    "frantic",
+    "free",
+    "freezing",
+    "frequent",
+    "fresh",
+    "fretful",
+    "friendly",
+    "frightened",
+    "frightening",
+    "full",
+    "fumbling",
+    "functional",
+    "funny",
+    "furry",
+    "furtive",
+    "future",
+    "futuristic",
+    "fuzzy",
+    "gabby",
+    "gainful",
+    "gamy",
+    "gaping",
+    "garrulous",
+    "gaudy",
+    "general",
+    "gentle",
+    "giant",
+    "giddy",
+    "gifted",
+    "gigantic",
+    "glamorous",
+    "gleaming",
+    "glib",
+    "glistening",
+    "glorious",
+    "glossy",
+    "godly",
+    "good",
+    "goofy",
+    "gorgeous",
+    "graceful",
+    "grandiose",
+    "grateful",
+    "gratis",
+    "gray",
+    "greasy",
+    "great",
+    "greedy",
+    "green",
+    "grey",
+    "grieving",
+    "groovy",
+    "grotesque",
+    "grouchy",
+    "grubby",
+    "gruesome",
+    "grumpy",
+    "guarded",
+    "guiltless",
+    "gullible",
+    "gusty",
+    "guttural",
+    "habitual",
+    "half",
+    "hallowed",
+    "halting",
+    "handsome",
+    "handsomely",
+    "handy",
+    "hanging",
+    "hapless",
+    "happy",
+    "hard",
+    "hard-to-find",
+    "harmonious",
+    "harsh",
+    "hateful",
+    "heady",
+    "healthy",
+    "heartbreaking",
+    "heavenly",
+    "heavy",
+    "hellish",
+    "helpful",
+    "helpless",
+    "hesitant",
+    "hideous",
+    "high",
+    "highfalutin",
+    "high-pitched",
+    "hilarious",
+    "hissing",
+    "historical",
+    "holistic",
+    "hollow",
+    "homeless",
+    "homely",
+    "honorable",
+    "horrible",
+    "hospitable",
+    "hot",
+    "huge",
+    "hulking",
+    "humdrum",
+    "humorous",
+    "hungry",
+    "hurried",
+    "hurt",
+    "hushed",
+    "husky",
+    "hypnotic",
+    "hysterical",
+    "icky",
+    "icy",
+    "idiotic",
+    "ignorant",
+    "ill",
+    "illegal",
+    "ill-fated",
+    "ill-informed",
+    "illustrious",
+    "imaginary",
+    "immense",
+    "imminent",
+    "impartial",
+    "imperfect",
+    "impolite",
+    "important",
+    "imported",
+    "impossible",
+    "incandescent",
+    "incompetent",
+    "inconclusive",
+    "industrious",
+    "incredible",
+    "inexpensive",
+    "infamous",
+    "innate",
+    "innocent",
+    "inquisitive",
+    "insidious",
+    "instinctive",
+    "intelligent",
+    "interesting",
+    "internal",
+    "invincible",
+    "irate",
+    "irritating",
+    "itchy",
+    "jaded",
+    "jagged",
+    "jazzy",
+    "jealous",
+    "jittery",
+    "jobless",
+    "jolly",
+    "joyous",
+    "judicious",
+    "juicy",
+    "jumbled",
+    "jumpy",
+    "juvenile",
+    "kaput",
+    "keen",
+    "kind",
+    "kindhearted",
+    "kindly",
+    "knotty",
+    "knowing",
+    "knowledgeable",
+    "known",
+    "labored",
+    "lackadaisical",
+    "lacking",
+    "lame",
+    "lamentable",
+    "languid",
+    "large",
+    "last",
+    "late",
+    "laughable",
+    "lavish",
+    "lazy",
+    "lean",
+    "learned",
+    "left",
+    "legal",
+    "lethal",
+    "level",
+    "lewd",
+    "light",
+    "like",
+    "likeable",
+    "limping",
+    "literate",
+    "little",
+    "lively",
+    "lively",
+    "living",
+    "lonely",
+    "long",
+    "longing",
+    "long-term",
+    "loose",
+    "lopsided",
+    "loud",
+    "loutish",
+    "lovely",
+    "loving",
+    "low",
+    "lowly",
+    "lucky",
+    "ludicrous",
+    "lumpy",
+    "lush",
+    "luxuriant",
+    "lying",
+    "lyrical",
+    "macabre",
+    "macho",
+    "maddening",
+    "madly",
+    "magenta",
+    "magical",
+    "magnificent",
+    "majestic",
+    "makeshift",
+    "male",
+    "malicious",
+    "mammoth",
+    "maniacal",
+    "many",
+    "marked",
+    "massive",
+    "married",
+    "marvelous",
+    "material",
+    "materialistic",
+    "mature",
+    "mean",
+    "measly",
+    "meaty",
+    "medical",
+    "meek",
+    "mellow",
+    "melodic",
+    "melted",
+    "merciful",
+    "mere",
+    "messy",
+    "mighty",
+    "military",
+    "milky",
+    "mindless",
+    "miniature",
+    "minor",
+    "miscreant",
+    "misty",
+    "mixed",
+    "moaning",
+    "modern",
+    "moldy",
+    "momentous",
+    "motionless",
+    "mountainous",
+    "muddled",
+    "mundane",
+    "murky",
+    "mushy",
+    "mute",
+    "mysterious",
+    "naive",
+    "nappy",
+    "narrow",
+    "nasty",
+    "natural",
+    "naughty",
+    "nauseating",
+    "near",
+    "neat",
+    "nebulous",
+    "necessary",
+    "needless",
+    "needy",
+    "neighborly",
+    "nervous",
+    "new",
+    "next",
+    "nice",
+    "nifty",
+    "nimble",
+    "nine",
+    "nippy",
+    "noiseless",
+    "noisy",
+    "nonchalant",
+    "nondescript",
+    "nonstop",
+    "normal",
+    "nostalgic",
+    "nosy",
+    "noxious",
+    "null",
+    "numberless",
+    "numerous",
+    "nutritious",
+    "nutty",
+    "oafish",
+    "obedient",
+    "obeisant",
+    "obese",
+    "obnoxious",
+    "obscene",
+    "obsequious",
+    "observant",
+    "obsolete",
+    "obtainable",
+    "oceanic",
+    "odd",
+    "offbeat",
+    "old",
+    "old-fashioned",
+    "omniscient",
+    "one",
+    "onerous",
+    "open",
+    "opposite",
+    "optimal",
+    "orange",
+    "ordinary",
+    "organic",
+    "ossified",
+    "outgoing",
+    "outrageous",
+    "outstanding",
+    "oval",
+    "overconfident",
+    "overjoyed",
+    "overrated",
+    "overt",
+    "overwrought",
+    "painful",
+    "painstaking",
+    "pale",
+    "paltry",
+    "panicky",
+    "panoramic",
+    "parallel",
+    "parched",
+    "parsimonious",
+    "past",
+    "pastoral",
+    "pathetic",
+    "peaceful",
+    "penitent",
+    "perfect",
+    "periodic",
+    "permissible",
+    "perpetual",
+    "petite",
+    "petite",
+    "phobic",
+    "physical",
+    "picayune",
+    "pink",
+    "piquant",
+    "placid",
+    "plain",
+    "plant",
+    "plastic",
+    "plausible",
+    "pleasant",
+    "plucky",
+    "pointless",
+    "poised",
+    "polite",
+    "political",
+    "poor",
+    "possessive",
+    "possible",
+    "powerful",
+    "precious",
+    "premium",
+    "present",
+    "pretty",
+    "previous",
+    "pricey",
+    "prickly",
+    "private",
+    "probable",
+    "productive",
+    "profuse",
+    "protective",
+    "proud",
+    "psychedelic",
+    "psychotic",
+    "public",
+    "puffy",
+    "pumped",
+    "puny",
+    "purple",
+    "purring",
+    "pushy",
+    "puzzled",
+    "puzzling",
+    "quack",
+    "quaint",
+    "quarrelsome",
+    "questionable",
+    "quick",
+    "quickest",
+    "quiet",
+    "quirky",
+    "quixotic",
+    "quizzical",
+    "rabid",
+    "racial",
+    "ragged",
+    "rainy",
+    "rambunctious",
+    "rampant",
+    "rapid",
+    "rare",
+    "raspy",
+    "ratty",
+    "ready",
+    "real",
+    "rebel",
+    "receptive",
+    "recondite",
+    "red",
+    "redundant",
+    "reflective",
+    "regular",
+    "relieved",
+    "remarkable",
+    "reminiscent",
+    "repulsive",
+    "resolute",
+    "resonant",
+    "responsible",
+    "rhetorical",
+    "rich",
+    "right",
+    "righteous",
+    "rightful",
+    "rigid",
+    "ripe",
+    "ritzy",
+    "roasted",
+    "robust",
+    "romantic",
+    "roomy",
+    "rotten",
+    "rough",
+    "round",
+    "royal",
+    "ruddy",
+    "rude",
+    "rural",
+    "rustic",
+    "ruthless",
+    "sable",
+    "sad",
+    "safe",
+    "salty",
+    "same",
+    "sassy",
+    "satisfying",
+    "savory",
+    "scandalous",
+    "scarce",
+    "scared",
+    "scary",
+    "scattered",
+    "scientific",
+    "scintillating",
+    "scrawny",
+    "screeching",
+    "second",
+    "second-hand",
+    "secret",
+    "secretive",
+    "sedate",
+    "seemly",
+    "selective",
+    "selfish",
+    "separate",
+    "serious",
+    "shaggy",
+    "shaky",
+    "shallow",
+    "sharp",
+    "shiny",
+    "shivering",
+    "shocking",
+    "short",
+    "shrill",
+    "shut",
+    "shy",
+    "sick",
+    "silent",
+    "silent",
+    "silky",
+    "silly",
+    "simple",
+    "simplistic",
+    "sincere",
+    "six",
+    "skillful",
+    "skinny",
+    "sleepy",
+    "slim",
+    "slimy",
+    "slippery",
+    "sloppy",
+    "slow",
+    "small",
+    "smart",
+    "smelly",
+    "smiling",
+    "smoggy",
+    "smooth",
+    "sneaky",
+    "snobbish",
+    "snotty",
+    "soft",
+    "soggy",
+    "solid",
+    "somber",
+    "sophisticated",
+    "sordid",
+    "sore",
+    "sore",
+    "sour",
+    "sparkling",
+    "special",
+    "spectacular",
+    "spicy",
+    "spiffy",
+    "spiky",
+    "spiritual",
+    "spiteful",
+    "splendid",
+    "spooky",
+    "spotless",
+    "spotted",
+    "spotty",
+    "spurious",
+    "squalid",
+    "square",
+    "squealing",
+    "squeamish",
+    "staking",
+    "stale",
+    "standing",
+    "statuesque",
+    "steadfast",
+    "steady",
+    "steep",
+    "stereotyped",
+    "sticky",
+    "stiff",
+    "stimulating",
+    "stingy",
+    "stormy",
+    "straight",
+    "strange",
+    "striped",
+    "strong",
+    "stupendous",
+    "stupid",
+    "sturdy",
+    "subdued",
+    "subsequent",
+    "substantial",
+    "successful",
+    "succinct",
+    "sudden",
+    "sulky",
+    "super",
+    "superb",
+    "superficial",
+    "supreme",
+    "swanky",
+    "sweet",
+    "sweltering",
+    "swift",
+    "symptomatic",
+    "synonymous",
+    "taboo",
+    "tacit",
+    "tacky",
+    "talented",
+    "tall",
+    "tame",
+    "tan",
+    "tangible",
+    "tangy",
+    "tart",
+    "tasteful",
+    "tasteless",
+    "tasty",
+    "tawdry",
+    "tearful",
+    "tedious",
+    "teeny",
+    "teeny-tiny",
+    "telling",
+    "temporary",
+    "ten",
+    "tender",
+    "tense",
+    "tense",
+    "tenuous",
+    "terrible",
+    "terrific",
+    "tested",
+    "testy",
+    "thankful",
+    "therapeutic",
+    "thick",
+    "thin",
+    "thinkable",
+    "third",
+    "thirsty",
+    "thirsty",
+    "thoughtful",
+    "thoughtless",
+    "threatening",
+    "three",
+    "thundering",
+    "tidy",
+    "tight",
+    "tightfisted",
+    "tiny",
+    "tired",
+    "tiresome",
+    "toothsome",
+    "torpid",
+    "tough",
+    "towering",
+    "tranquil",
+    "trashy",
+    "tremendous",
+    "tricky",
+    "trite",
+    "troubled",
+    "truculent",
+    "true",
+    "truthful",
+    "two",
+    "typical",
+    "ubiquitous",
+    "ugliest",
+    "ugly",
+    "ultra",
+    "unable",
+    "unaccountable",
+    "unadvised",
+    "unarmed",
+    "unbecoming",
+    "unbiased",
+    "uncovered",
+    "understood",
+    "undesirable",
+    "unequal",
+    "unequaled",
+    "uneven",
+    "unhealthy",
+    "uninterested",
+    "unique",
+    "unkempt",
+    "unknown",
+    "unnatural",
+    "unruly",
+    "unsightly",
+    "unsuitable",
+    "untidy",
+    "unused",
+    "unusual",
+    "unwieldy",
+    "unwritten",
+    "upbeat",
+    "uppity",
+    "upset",
+    "uptight",
+    "used",
+    "useful",
+    "useless",
+    "utopian",
+    "utter",
+    "uttermost",
+    "vacuous",
+    "vagabond",
+    "vague",
+    "valuable",
+    "various",
+    "vast",
+    "vengeful",
+    "venomous",
+    "verdant",
+    "versed",
+    "victorious",
+    "vigorous",
+    "violent",
+    "violet",
+    "vivacious",
+    "voiceless",
+    "volatile",
+    "voracious",
+    "vulgar",
+    "wacky",
+    "waggish",
+    "waiting",
+    "wakeful",
+    "wandering",
+    "wanting",
+    "warlike",
+    "warm",
+    "wary",
+    "wasteful",
+    "watery",
+    "weak",
+    "wealthy",
+    "weary",
+    "well-groomed",
+    "well-made",
+    "well-off",
+    "well-to-do",
+    "wet",
+    "whimsical",
+    "whispering",
+    "white",
+    "whole",
+    "wholesale",
+    "wicked",
+    "wide",
+    "wide-eyed",
+    "wiggly",
+    "wild",
+    "willing",
+    "windy",
+    "wiry",
+    "wise",
+    "wistful",
+    "witty",
+    "woebegone",
+    "womanly",
+    "wonderful",
+    "wooden",
+    "woozy",
+    "workable",
+    "worried",
+    "worthless",
+    "wrathful",
+    "wretched",
+    "wrong",
+    "wry",
+    "yellow",
+    "yielding",
+    "young",
+    "youthful",
+    "yummy",
+    "zany",
+    "zealous",
+    "zesty",
+    "zippy",
+    "zonked"
+  ]
+}
diff --git a/backend/src/realtime/realtime-note/random-word-lists/random-words.json.license b/backend/src/realtime/realtime-note/random-word-lists/random-words.json.license
new file mode 100644
index 000000000..8f24bb228
--- /dev/null
+++ b/backend/src/realtime/realtime-note/random-word-lists/random-words.json.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: The author of https://www.randomlists.com/
+
+SPDX-License-Identifier: CC0-1.0
diff --git a/backend/src/realtime/realtime-note/realtime-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts
new file mode 100644
index 000000000..8cbd7954d
--- /dev/null
+++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts
@@ -0,0 +1,156 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import {
+  MessageTransporter,
+  MockedBackendMessageTransporter,
+  YDocSyncServerAdapter,
+} from '@hedgedoc/commons';
+import * as HedgeDocCommonsModule from '@hedgedoc/commons';
+import { Mock } from 'ts-mockery';
+
+import { Note } from '../../notes/note.entity';
+import { User } from '../../users/user.entity';
+import * as NameRandomizerModule from './random-word-lists/name-randomizer';
+import { RealtimeConnection } from './realtime-connection';
+import { RealtimeNote } from './realtime-note';
+import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter';
+import * as RealtimeUserStatusModule from './realtime-user-status-adapter';
+
+jest.mock('./random-word-lists/name-randomizer');
+jest.mock('./realtime-user-status-adapter');
+jest.mock(
+  '@hedgedoc/commons',
+  () =>
+    ({
+      ...jest.requireActual('@hedgedoc/commons'),
+      // eslint-disable-next-line @typescript-eslint/naming-convention
+      YDocSyncServerAdapter: jest.fn(() =>
+        Mock.of<YDocSyncServerAdapter>({
+          setYDoc: jest.fn(),
+        }),
+      ),
+    } as Record<string, unknown>),
+);
+
+describe('websocket connection', () => {
+  let mockedRealtimeNote: RealtimeNote;
+  let mockedUser: User;
+  let mockedMessageTransporter: MessageTransporter;
+
+  beforeEach(() => {
+    mockedRealtimeNote = new RealtimeNote(Mock.of<Note>({}), '');
+    mockedUser = Mock.of<User>({});
+
+    mockedMessageTransporter = new MockedBackendMessageTransporter('');
+  });
+
+  afterAll(() => {
+    jest.resetAllMocks();
+    jest.resetModules();
+  });
+
+  it('returns the correct transporter', () => {
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+    expect(sut.getTransporter()).toBe(mockedMessageTransporter);
+  });
+
+  it('returns the correct realtime note', () => {
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+    expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote);
+  });
+
+  it('returns the correct realtime user status', () => {
+    const realtimeUserStatus = Mock.of<RealtimeUserStatusAdapter>();
+    jest
+      .spyOn(RealtimeUserStatusModule, 'RealtimeUserStatusAdapter')
+      .mockImplementation(() => realtimeUserStatus);
+
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+
+    expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus);
+  });
+
+  it('returns the correct sync adapter', () => {
+    const yDocSyncServerAdapter = Mock.of<YDocSyncServerAdapter>({
+      setYDoc: jest.fn(),
+    });
+    jest
+      .spyOn(HedgeDocCommonsModule, 'YDocSyncServerAdapter')
+      .mockImplementation(() => yDocSyncServerAdapter);
+
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+
+    expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter);
+  });
+
+  it('removes the client from the note on transporter disconnect', () => {
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+
+    const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
+
+    mockedMessageTransporter.disconnect();
+
+    expect(removeClientSpy).toHaveBeenCalledWith(sut);
+  });
+
+  it('saves the correct user', () => {
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+
+    expect(sut.getUser()).toBe(mockedUser);
+  });
+
+  it('returns the correct username', () => {
+    const mockedUserWithUsername = Mock.of<User>({ displayName: 'MockUser' });
+
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUserWithUsername,
+      mockedRealtimeNote,
+    );
+
+    expect(sut.getDisplayName()).toBe('MockUser');
+  });
+
+  it('returns a fallback if no username has been set', () => {
+    const randomName = 'I am a random name';
+
+    jest
+      .spyOn(NameRandomizerModule, 'generateRandomName')
+      .mockReturnValue(randomName);
+
+    const sut = new RealtimeConnection(
+      mockedMessageTransporter,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+
+    expect(sut.getDisplayName()).toBe(randomName);
+  });
+});
diff --git a/backend/src/realtime/realtime-note/realtime-connection.ts b/backend/src/realtime/realtime-note/realtime-connection.ts
new file mode 100644
index 000000000..f43765f51
--- /dev/null
+++ b/backend/src/realtime/realtime-note/realtime-connection.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { MessageTransporter, YDocSyncServerAdapter } from '@hedgedoc/commons';
+import { Logger } from '@nestjs/common';
+
+import { User } from '../../users/user.entity';
+import { generateRandomName } from './random-word-lists/name-randomizer';
+import { RealtimeNote } from './realtime-note';
+import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter';
+
+/**
+ * Manages the connection to a specific client.
+ */
+export class RealtimeConnection {
+  protected readonly logger = new Logger(RealtimeConnection.name);
+  private readonly transporter: MessageTransporter;
+  private readonly yDocSyncAdapter: YDocSyncServerAdapter;
+  private readonly realtimeUserStateAdapter: RealtimeUserStatusAdapter;
+
+  private displayName: string;
+
+  /**
+   * Instantiates the connection wrapper.
+   *
+   * @param messageTransporter The message transporter that handles the communication with the client.
+   * @param user The user of the client
+   * @param realtimeNote The {@link RealtimeNote} that the client connected to.
+   * @throws Error if the socket is not open
+   */
+  constructor(
+    messageTransporter: MessageTransporter,
+    private user: User | null,
+    private realtimeNote: RealtimeNote,
+  ) {
+    this.displayName = user?.displayName ?? generateRandomName();
+    this.transporter = messageTransporter;
+
+    this.transporter.on('disconnected', () => {
+      realtimeNote.removeClient(this);
+    });
+    this.yDocSyncAdapter = new YDocSyncServerAdapter(this.transporter);
+    this.yDocSyncAdapter.setYDoc(realtimeNote.getRealtimeDoc());
+    this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
+      this.user?.username ?? null,
+      this.getDisplayName(),
+      this,
+    );
+  }
+
+  public getRealtimeUserStateAdapter(): RealtimeUserStatusAdapter {
+    return this.realtimeUserStateAdapter;
+  }
+
+  public getTransporter(): MessageTransporter {
+    return this.transporter;
+  }
+
+  public getUser(): User | null {
+    return this.user;
+  }
+
+  public getSyncAdapter(): YDocSyncServerAdapter {
+    return this.yDocSyncAdapter;
+  }
+
+  public getDisplayName(): string {
+    return this.displayName;
+  }
+
+  public getRealtimeNote(): RealtimeNote {
+    return this.realtimeNote;
+  }
+}
diff --git a/backend/src/realtime/realtime-note/realtime-note-store.spec.ts b/backend/src/realtime/realtime-note/realtime-note-store.spec.ts
index 40fdd17ef..89071bf0f 100644
--- a/backend/src/realtime/realtime-note/realtime-note-store.spec.ts
+++ b/backend/src/realtime/realtime-note/realtime-note-store.spec.ts
@@ -9,9 +9,6 @@ import { Note } from '../../notes/note.entity';
 import * as realtimeNoteModule from './realtime-note';
 import { RealtimeNote } from './realtime-note';
 import { RealtimeNoteStore } from './realtime-note-store';
-import { mockRealtimeNote } from './test-utils/mock-realtime-note';
-import { WebsocketAwareness } from './websocket-awareness';
-import { WebsocketDoc } from './websocket-doc';
 
 describe('RealtimeNoteStore', () => {
   let realtimeNoteStore: RealtimeNoteStore;
@@ -22,22 +19,21 @@ describe('RealtimeNoteStore', () => {
   const mockedNoteId = 4711;
 
   beforeEach(async () => {
-    jest.resetAllMocks();
-    jest.resetModules();
-
     realtimeNoteStore = new RealtimeNoteStore();
 
     mockedNote = Mock.of<Note>({ id: mockedNoteId });
-    mockedRealtimeNote = mockRealtimeNote(
-      mockedNote,
-      Mock.of<WebsocketDoc>(),
-      Mock.of<WebsocketAwareness>(),
-    );
+    mockedRealtimeNote = new RealtimeNote(mockedNote, '');
     realtimeNoteConstructorSpy = jest
       .spyOn(realtimeNoteModule, 'RealtimeNote')
       .mockReturnValue(mockedRealtimeNote);
   });
 
+  afterEach(() => {
+    jest.restoreAllMocks();
+    jest.resetAllMocks();
+    jest.resetModules();
+  });
+
   it("can create a new realtime note if it doesn't exist yet", () => {
     expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
       mockedRealtimeNote,
diff --git a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts
index 29dcfdda5..9a399af0d 100644
--- a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts
+++ b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts
@@ -14,17 +14,12 @@ import { RevisionsService } from '../../revisions/revisions.service';
 import { RealtimeNote } from './realtime-note';
 import { RealtimeNoteStore } from './realtime-note-store';
 import { RealtimeNoteService } from './realtime-note.service';
-import { mockAwareness } from './test-utils/mock-awareness';
-import { mockRealtimeNote } from './test-utils/mock-realtime-note';
-import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
-import { WebsocketDoc } from './websocket-doc';
 
 describe('RealtimeNoteService', () => {
   const mockedContent = 'mockedContent';
   const mockedNoteId = 4711;
-  let websocketDoc: WebsocketDoc;
-  let mockedNote: Note;
-  let mockedRealtimeNote: RealtimeNote;
+  let note: Note;
+  let realtimeNote: RealtimeNote;
   let realtimeNoteService: RealtimeNoteService;
   let revisionsService: RevisionsService;
   let realtimeNoteStore: RealtimeNoteStore;
@@ -46,7 +41,7 @@ describe('RealtimeNoteService', () => {
     jest
       .spyOn(revisionsService, 'getLatestRevision')
       .mockImplementation((note: Note) =>
-        note === mockedNote && latestRevisionExists
+        note.id === mockedNoteId && latestRevisionExists
           ? Promise.resolve(
               Mock.of<Revision>({
                 content: mockedContent,
@@ -60,13 +55,8 @@ describe('RealtimeNoteService', () => {
     jest.resetAllMocks();
     jest.resetModules();
 
-    websocketDoc = mockWebsocketDoc();
-    mockedNote = Mock.of<Note>({ id: mockedNoteId });
-    mockedRealtimeNote = mockRealtimeNote(
-      mockedNote,
-      websocketDoc,
-      mockAwareness(),
-    );
+    note = Mock.of<Note>({ id: mockedNoteId });
+    realtimeNote = new RealtimeNote(note, mockedContent);
 
     revisionsService = Mock.of<RevisionsService>({
       getLatestRevision: jest.fn(),
@@ -108,18 +98,15 @@ describe('RealtimeNoteService', () => {
     jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
     jest
       .spyOn(realtimeNoteStore, 'create')
-      .mockImplementation(() => mockedRealtimeNote);
+      .mockImplementation(() => realtimeNote);
     mockedAppConfig.persistInterval = 0;
 
     await expect(
-      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
-    ).resolves.toBe(mockedRealtimeNote);
+      realtimeNoteService.getOrCreateRealtimeNote(note),
+    ).resolves.toBe(realtimeNote);
 
     expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
-    expect(realtimeNoteStore.create).toHaveBeenCalledWith(
-      mockedNote,
-      mockedContent,
-    );
+    expect(realtimeNoteStore.create).toHaveBeenCalledWith(note, mockedContent);
     expect(setIntervalSpy).not.toHaveBeenCalled();
   });
 
@@ -129,10 +116,10 @@ describe('RealtimeNoteService', () => {
       jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
       jest
         .spyOn(realtimeNoteStore, 'create')
-        .mockImplementation(() => mockedRealtimeNote);
+        .mockImplementation(() => realtimeNote);
       mockedAppConfig.persistInterval = 10;
 
-      await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
+      await realtimeNoteService.getOrCreateRealtimeNote(note);
 
       expect(setIntervalSpy).toHaveBeenCalledWith(
         expect.any(Function),
@@ -146,11 +133,11 @@ describe('RealtimeNoteService', () => {
       jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
       jest
         .spyOn(realtimeNoteStore, 'create')
-        .mockImplementation(() => mockedRealtimeNote);
+        .mockImplementation(() => realtimeNote);
       mockedAppConfig.persistInterval = 10;
 
-      await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
-      mockedRealtimeNote.emit('destroy');
+      await realtimeNoteService.getOrCreateRealtimeNote(note);
+      realtimeNote.emit('destroy');
       expect(deleteIntervalSpy).toHaveBeenCalled();
       expect(clearIntervalSpy).toHaveBeenCalled();
     });
@@ -162,7 +149,7 @@ describe('RealtimeNoteService', () => {
     jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
 
     await expect(
-      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
+      realtimeNoteService.getOrCreateRealtimeNote(note),
     ).rejects.toBe(`Revision for note mockedNoteId not found.`);
     expect(realtimeNoteStore.create).not.toHaveBeenCalled();
     expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
@@ -174,53 +161,46 @@ describe('RealtimeNoteService', () => {
     jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
     jest
       .spyOn(realtimeNoteStore, 'create')
-      .mockImplementation(() => mockedRealtimeNote);
+      .mockImplementation(() => realtimeNote);
 
     await expect(
-      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
-    ).resolves.toBe(mockedRealtimeNote);
+      realtimeNoteService.getOrCreateRealtimeNote(note),
+    ).resolves.toBe(realtimeNote);
 
     jest
       .spyOn(realtimeNoteStore, 'find')
-      .mockImplementation(() => mockedRealtimeNote);
+      .mockImplementation(() => realtimeNote);
 
     await expect(
-      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
-    ).resolves.toBe(mockedRealtimeNote);
+      realtimeNoteService.getOrCreateRealtimeNote(note),
+    ).resolves.toBe(realtimeNote);
     expect(realtimeNoteStore.create).toHaveBeenCalledTimes(1);
   });
 
   it('saves a realtime note if it gets destroyed', async () => {
     mockGetLatestRevision(true);
-    const mockedCurrentContent = 'mockedCurrentContent';
 
     jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
     jest
       .spyOn(realtimeNoteStore, 'create')
-      .mockImplementation(() => mockedRealtimeNote);
-    jest
-      .spyOn(websocketDoc, 'getCurrentContent')
-      .mockReturnValue(mockedCurrentContent);
+      .mockImplementation(() => realtimeNote);
 
-    await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
+    await realtimeNoteService.getOrCreateRealtimeNote(note);
 
     const createRevisionSpy = jest
       .spyOn(revisionsService, 'createRevision')
       .mockImplementation(() => Promise.resolve(Mock.of<Revision>()));
 
-    mockedRealtimeNote.emit('beforeDestroy');
-    expect(createRevisionSpy).toHaveBeenCalledWith(
-      mockedNote,
-      mockedCurrentContent,
-    );
+    realtimeNote.emit('beforeDestroy');
+    expect(createRevisionSpy).toHaveBeenCalledWith(note, mockedContent);
   });
 
   it('destroys every realtime note on application shutdown', () => {
     jest
       .spyOn(realtimeNoteStore, 'getAllRealtimeNotes')
-      .mockReturnValue([mockedRealtimeNote]);
+      .mockReturnValue([realtimeNote]);
 
-    const destroySpy = jest.spyOn(mockedRealtimeNote, 'destroy');
+    const destroySpy = jest.spyOn(realtimeNote, 'destroy');
 
     realtimeNoteService.beforeApplicationShutdown();
 
diff --git a/backend/src/realtime/realtime-note/realtime-note.service.ts b/backend/src/realtime/realtime-note/realtime-note.service.ts
index 6836814cc..a4d0f982a 100644
--- a/backend/src/realtime/realtime-note/realtime-note.service.ts
+++ b/backend/src/realtime/realtime-note/realtime-note.service.ts
@@ -42,7 +42,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
     this.revisionsService
       .createRevision(
         realtimeNote.getNote(),
-        realtimeNote.getYDoc().getCurrentContent(),
+        realtimeNote.getRealtimeDoc().getCurrentContent(),
       )
       .catch((reason) => this.logger.error(reason));
   }
diff --git a/backend/src/realtime/realtime-note/realtime-note.spec.ts b/backend/src/realtime/realtime-note/realtime-note.spec.ts
index e4099dfc3..2ad20e5b7 100644
--- a/backend/src/realtime/realtime-note/realtime-note.spec.ts
+++ b/backend/src/realtime/realtime-note/realtime-note.spec.ts
@@ -3,39 +3,20 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import {
-  encodeDocumentDeletedMessage,
-  encodeMetadataUpdatedMessage,
-} from '@hedgedoc/commons';
+import { MessageType, RealtimeDoc } from '@hedgedoc/commons';
+import * as hedgedocCommonsModule from '@hedgedoc/commons';
 import { Mock } from 'ts-mockery';
 
 import { Note } from '../../notes/note.entity';
 import { RealtimeNote } from './realtime-note';
-import { mockAwareness } from './test-utils/mock-awareness';
-import { mockConnection } from './test-utils/mock-connection';
-import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
-import * as websocketAwarenessModule from './websocket-awareness';
-import { WebsocketAwareness } from './websocket-awareness';
-import * as websocketDocModule from './websocket-doc';
-import { WebsocketDoc } from './websocket-doc';
+import { MockConnectionBuilder } from './test-utils/mock-connection';
+
+jest.mock('@hedgedoc/commons');
 
 describe('realtime note', () => {
-  let mockedDoc: WebsocketDoc;
-  let mockedAwareness: WebsocketAwareness;
   let mockedNote: Note;
 
   beforeEach(() => {
-    jest.resetAllMocks();
-    jest.resetModules();
-    mockedDoc = mockWebsocketDoc();
-    mockedAwareness = mockAwareness();
-    jest
-      .spyOn(websocketDocModule, 'WebsocketDoc')
-      .mockImplementation(() => mockedDoc);
-    jest
-      .spyOn(websocketAwarenessModule, 'WebsocketAwareness')
-      .mockImplementation(() => mockedAwareness);
-
     mockedNote = Mock.of<Note>({ id: 4711 });
   });
 
@@ -51,8 +32,7 @@ describe('realtime note', () => {
 
   it('can connect and disconnect clients', () => {
     const sut = new RealtimeNote(mockedNote, 'nothing');
-    const client1 = mockConnection(true);
-    sut.addClient(client1);
+    const client1 = new MockConnectionBuilder(sut).build();
     expect(sut.getConnections()).toStrictEqual([client1]);
     expect(sut.hasConnections()).toBeTruthy();
     sut.removeClient(client1);
@@ -60,19 +40,22 @@ describe('realtime note', () => {
     expect(sut.hasConnections()).toBeFalsy();
   });
 
-  it('creates a y-doc and y-awareness', () => {
-    const sut = new RealtimeNote(mockedNote, 'nothing');
-    expect(sut.getYDoc()).toBe(mockedDoc);
-    expect(sut.getAwareness()).toBe(mockedAwareness);
+  it('creates a y-doc', () => {
+    const initialContent = 'nothing';
+    const mockedDoc = new RealtimeDoc(initialContent);
+    const docSpy = jest
+      .spyOn(hedgedocCommonsModule, 'RealtimeDoc')
+      .mockReturnValue(mockedDoc);
+    const sut = new RealtimeNote(mockedNote, initialContent);
+    expect(docSpy).toHaveBeenCalledWith(initialContent);
+    expect(sut.getRealtimeDoc()).toBe(mockedDoc);
   });
 
-  it('destroys y-doc and y-awareness on self-destruction', () => {
+  it('destroys y-doc on self-destruction', () => {
     const sut = new RealtimeNote(mockedNote, 'nothing');
-    const docDestroy = jest.spyOn(mockedDoc, 'destroy');
-    const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
+    const docDestroy = jest.spyOn(sut.getRealtimeDoc(), 'destroy');
     sut.destroy();
     expect(docDestroy).toHaveBeenCalled();
-    expect(awarenessDestroy).toHaveBeenCalled();
   });
 
   it('emits destroy event on destruction', async () => {
@@ -94,33 +77,38 @@ describe('realtime note', () => {
 
   it('announcePermissionChange to all clients', () => {
     const sut = new RealtimeNote(mockedNote, 'nothing');
-    const client1 = mockConnection(true);
-    sut.addClient(client1);
-    const client2 = mockConnection(true);
-    sut.addClient(client2);
-    const metadataMessage = encodeMetadataUpdatedMessage();
+
+    const client1 = new MockConnectionBuilder(sut).build();
+    const client2 = new MockConnectionBuilder(sut).build();
+
+    const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
+    const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
+
+    const metadataMessage = { type: MessageType.METADATA_UPDATED };
     sut.announcePermissionChange();
-    expect(client1.send).toHaveBeenCalledWith(metadataMessage);
-    expect(client2.send).toHaveBeenCalledWith(metadataMessage);
+    expect(sendMessage1Spy).toHaveBeenCalledWith(metadataMessage);
+    expect(sendMessage2Spy).toHaveBeenCalledWith(metadataMessage);
     sut.removeClient(client2);
     sut.announcePermissionChange();
-    expect(client1.send).toHaveBeenCalledTimes(2);
-    expect(client2.send).toHaveBeenCalledTimes(1);
+    expect(sendMessage1Spy).toHaveBeenCalledTimes(2);
+    expect(sendMessage2Spy).toHaveBeenCalledTimes(1);
   });
 
   it('announceNoteDeletion to all clients', () => {
     const sut = new RealtimeNote(mockedNote, 'nothing');
-    const client1 = mockConnection(true);
-    sut.addClient(client1);
-    const client2 = mockConnection(true);
-    sut.addClient(client2);
-    const deletedMessage = encodeDocumentDeletedMessage();
+    const client1 = new MockConnectionBuilder(sut).build();
+    const client2 = new MockConnectionBuilder(sut).build();
+
+    const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
+    const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
+
+    const deletedMessage = { type: MessageType.DOCUMENT_DELETED };
     sut.announceNoteDeletion();
-    expect(client1.send).toHaveBeenCalledWith(deletedMessage);
-    expect(client2.send).toHaveBeenCalledWith(deletedMessage);
+    expect(sendMessage1Spy).toHaveBeenCalledWith(deletedMessage);
+    expect(sendMessage2Spy).toHaveBeenCalledWith(deletedMessage);
     sut.removeClient(client2);
     sut.announceNoteDeletion();
-    expect(client1.send).toHaveBeenCalledTimes(2);
-    expect(client2.send).toHaveBeenCalledTimes(1);
+    expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage);
+    expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage);
   });
 });
diff --git a/backend/src/realtime/realtime-note/realtime-note.ts b/backend/src/realtime/realtime-note/realtime-note.ts
index fb535368b..f6a1d8912 100644
--- a/backend/src/realtime/realtime-note/realtime-note.ts
+++ b/backend/src/realtime/realtime-note/realtime-note.ts
@@ -3,52 +3,51 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import {
-  encodeDocumentDeletedMessage,
-  encodeMetadataUpdatedMessage,
-} from '@hedgedoc/commons';
+import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons';
 import { Logger } from '@nestjs/common';
 import { EventEmitter2, EventMap } from 'eventemitter2';
-import { Awareness } from 'y-protocols/awareness';
 
 import { Note } from '../../notes/note.entity';
-import { WebsocketAwareness } from './websocket-awareness';
-import { WebsocketConnection } from './websocket-connection';
-import { WebsocketDoc } from './websocket-doc';
+import { RealtimeConnection } from './realtime-connection';
 
-export interface MapType extends EventMap {
+export interface RealtimeNoteEventMap extends EventMap {
   destroy: () => void;
   beforeDestroy: () => void;
+  clientAdded: (client: RealtimeConnection) => void;
+  clientRemoved: (client: RealtimeConnection) => void;
+
+  yDocUpdate: (update: number[], origin: unknown) => void;
 }
 
 /**
  * Represents a note currently being edited by a number of clients.
  */
-export class RealtimeNote extends EventEmitter2<MapType> {
+export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
   protected logger: Logger;
-  private readonly websocketDoc: WebsocketDoc;
-  private readonly websocketAwareness: WebsocketAwareness;
-  private readonly clients = new Set<WebsocketConnection>();
+  private readonly doc: RealtimeDoc;
+  private readonly clients = new Set<RealtimeConnection>();
   private isClosing = false;
 
   constructor(private readonly note: Note, initialContent: string) {
     super();
     this.logger = new Logger(`${RealtimeNote.name} ${note.id}`);
-    this.websocketDoc = new WebsocketDoc(this, initialContent);
-    this.websocketAwareness = new WebsocketAwareness(this);
-    this.logger.debug(`New realtime session for note ${note.id} created.`);
+    this.doc = new RealtimeDoc(initialContent);
+    this.logger.debug(
+      `New realtime session for note ${note.id} created. Length of initial content: ${initialContent.length} characters`,
+    );
   }
 
   /**
    * Connects a new client to the note.
    *
-   * For this purpose a {@link WebsocketConnection} is created and added to the client map.
+   * For this purpose a {@link RealtimeConnection} is created and added to the client map.
    *
    * @param client the websocket connection to the client
    */
-  public addClient(client: WebsocketConnection): void {
+  public addClient(client: RealtimeConnection): void {
     this.clients.add(client);
-    this.logger.debug(`User '${client.getUsername()}' connected`);
+    this.logger.debug(`User '${client.getDisplayName()}' connected`);
+    this.emit('clientAdded', client);
   }
 
   /**
@@ -56,13 +55,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
    *
    * @param {WebSocket} client The websocket client that disconnects.
    */
-  public removeClient(client: WebsocketConnection): void {
+  public removeClient(client: RealtimeConnection): void {
     this.clients.delete(client);
     this.logger.debug(
-      `User '${client.getUsername()}' disconnected. ${
+      `User '${client.getDisplayName()}' disconnected. ${
         this.clients.size
       } clients left.`,
     );
+    this.emit('clientRemoved', client);
     if (!this.hasConnections() && !this.isClosing) {
       this.destroy();
     }
@@ -80,9 +80,8 @@ export class RealtimeNote extends EventEmitter2<MapType> {
     this.logger.debug('Destroying realtime note.');
     this.emit('beforeDestroy');
     this.isClosing = true;
-    this.websocketDoc.destroy();
-    this.websocketAwareness.destroy();
-    this.clients.forEach((value) => value.disconnect());
+    this.doc.destroy();
+    this.clients.forEach((value) => value.getTransporter().disconnect());
     this.emit('destroy');
   }
 
@@ -96,30 +95,21 @@ export class RealtimeNote extends EventEmitter2<MapType> {
   }
 
   /**
-   * Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note.
+   * Returns all {@link RealtimeConnection WebsocketConnections} currently hold by this note.
    *
-   * @return an array of {@link WebsocketConnection WebsocketConnections}
+   * @return an array of {@link RealtimeConnection WebsocketConnections}
    */
-  public getConnections(): WebsocketConnection[] {
+  public getConnections(): RealtimeConnection[] {
     return [...this.clients];
   }
 
   /**
-   * Get the {@link Doc YDoc} of the note.
+   * Get the {@link RealtimeDoc realtime note} of the note.
    *
-   * @return the {@link Doc YDoc} of the note
+   * @return the {@link RealtimeDoc realtime note} of the note
    */
-  public getYDoc(): WebsocketDoc {
-    return this.websocketDoc;
-  }
-
-  /**
-   * Get the {@link Awareness YAwareness} of the note.
-   *
-   * @return the {@link Awareness YAwareness} of the note
-   */
-  public getAwareness(): Awareness {
-    return this.websocketAwareness;
+  public getRealtimeDoc(): RealtimeDoc {
+    return this.doc;
   }
 
   /**
@@ -135,14 +125,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
    * Announce to all clients that the permissions of the note have been changed.
    */
   public announcePermissionChange(): void {
-    this.sendToAllClients(encodeMetadataUpdatedMessage());
+    this.sendToAllClients({ type: MessageType.METADATA_UPDATED });
   }
 
   /**
    * Announce to all clients that the note has been deleted.
    */
   public announceNoteDeletion(): void {
-    this.sendToAllClients(encodeDocumentDeletedMessage());
+    this.sendToAllClients({ type: MessageType.DOCUMENT_DELETED });
   }
 
   /**
@@ -150,9 +140,9 @@ export class RealtimeNote extends EventEmitter2<MapType> {
    *
    * @param {Uint8Array} content The binary message to broadcast
    */
-  private sendToAllClients(content: Uint8Array): void {
+  private sendToAllClients(content: Message<MessageType>): void {
     this.getConnections().forEach((connection) => {
-      connection.send(content);
+      connection.getTransporter().sendMessage(content);
     });
   }
 }
diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts
new file mode 100644
index 000000000..634e0476b
--- /dev/null
+++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts
@@ -0,0 +1,229 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Message, MessageTransporter, MessageType } from '@hedgedoc/commons';
+import { Mock } from 'ts-mockery';
+
+import { Note } from '../../notes/note.entity';
+import { RealtimeConnection } from './realtime-connection';
+import { RealtimeNote } from './realtime-note';
+import { MockConnectionBuilder } from './test-utils/mock-connection';
+
+type SendMessageSpy = jest.SpyInstance<
+  void,
+  [Required<MessageTransporter['sendMessage']>]
+>;
+
+describe('realtime user status adapter', () => {
+  let client1: RealtimeConnection;
+  let client2: RealtimeConnection;
+  let client3: RealtimeConnection;
+  let client4: RealtimeConnection;
+
+  let sendMessage1Spy: SendMessageSpy;
+  let sendMessage2Spy: SendMessageSpy;
+  let sendMessage3Spy: SendMessageSpy;
+  let sendMessage4Spy: SendMessageSpy;
+
+  let realtimeNote: RealtimeNote;
+
+  const username1 = 'mock1';
+  const username2 = 'mock2';
+  const username3 = 'mock3';
+  const username4 = 'mock4';
+
+  beforeEach(() => {
+    realtimeNote = new RealtimeNote(
+      Mock.of<Note>({ id: 9876 }),
+      'mockedContent',
+    );
+    client1 = new MockConnectionBuilder(realtimeNote)
+      .withRealtimeUserState()
+      .withUsername(username1)
+      .build();
+    client2 = new MockConnectionBuilder(realtimeNote)
+      .withRealtimeUserState()
+      .withUsername(username2)
+      .build();
+    client3 = new MockConnectionBuilder(realtimeNote)
+      .withRealtimeUserState()
+      .withUsername(username3)
+      .build();
+    client4 = new MockConnectionBuilder(realtimeNote)
+      .withRealtimeUserState()
+      .withUsername(username4)
+      .build();
+
+    sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
+    sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
+    sendMessage3Spy = jest.spyOn(client3.getTransporter(), 'sendMessage');
+    sendMessage4Spy = jest.spyOn(client4.getTransporter(), 'sendMessage');
+
+    client1.getTransporter().sendReady();
+    client2.getTransporter().sendReady();
+    client3.getTransporter().sendReady();
+    //client 4 shouldn't be ready on purpose
+  });
+
+  it('can answer a state request', () => {
+    expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
+
+    client1.getTransporter().emit(MessageType.REALTIME_USER_STATE_REQUEST);
+
+    const expectedMessage1: Message<MessageType.REALTIME_USER_STATE_SET> = {
+      type: MessageType.REALTIME_USER_STATE_SET,
+      payload: [
+        {
+          active: true,
+          cursor: {
+            from: 0,
+            to: 0,
+          },
+          styleIndex: 1,
+          username: username2,
+          displayName: username2,
+        },
+        {
+          active: true,
+          cursor: {
+            from: 0,
+            to: 0,
+          },
+          styleIndex: 2,
+          username: username3,
+          displayName: username3,
+        },
+      ],
+    };
+    expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1);
+    expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
+  });
+
+  it('can save an cursor update', () => {
+    expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
+
+    const newFrom = Math.floor(Math.random() * 100);
+    const newTo = Math.floor(Math.random() * 100);
+
+    client1.getTransporter().emit(MessageType.REALTIME_USER_SINGLE_UPDATE, {
+      type: MessageType.REALTIME_USER_SINGLE_UPDATE,
+      payload: {
+        from: newFrom,
+        to: newTo,
+      },
+    });
+
+    const expectedMessage2: Message<MessageType.REALTIME_USER_STATE_SET> = {
+      type: MessageType.REALTIME_USER_STATE_SET,
+      payload: [
+        {
+          active: true,
+          cursor: {
+            from: newFrom,
+            to: newTo,
+          },
+          styleIndex: 0,
+          username: username1,
+          displayName: username1,
+        },
+        {
+          active: true,
+          cursor: {
+            from: 0,
+            to: 0,
+          },
+          styleIndex: 2,
+          username: username3,
+          displayName: username3,
+        },
+      ],
+    };
+
+    const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
+      type: MessageType.REALTIME_USER_STATE_SET,
+      payload: [
+        {
+          active: true,
+          cursor: {
+            from: newFrom,
+            to: newTo,
+          },
+          styleIndex: 0,
+          username: username1,
+          displayName: username1,
+        },
+        {
+          active: true,
+          cursor: {
+            from: 0,
+            to: 0,
+          },
+          styleIndex: 1,
+          username: username2,
+          displayName: username2,
+        },
+      ],
+    };
+
+    expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, expectedMessage2);
+    expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3);
+    expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
+  });
+
+  it('will inform other clients about removed client', () => {
+    expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
+
+    client2.getTransporter().disconnect();
+
+    const expectedMessage1: Message<MessageType.REALTIME_USER_STATE_SET> = {
+      type: MessageType.REALTIME_USER_STATE_SET,
+      payload: [
+        {
+          active: true,
+          cursor: {
+            from: 0,
+            to: 0,
+          },
+          styleIndex: 2,
+          username: username3,
+          displayName: username3,
+        },
+      ],
+    };
+
+    const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
+      type: MessageType.REALTIME_USER_STATE_SET,
+      payload: [
+        {
+          active: true,
+          cursor: {
+            from: 0,
+            to: 0,
+          },
+          styleIndex: 0,
+          username: username1,
+          displayName: username1,
+        },
+      ],
+    };
+
+    expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1);
+    expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
+    expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3);
+    expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts
new file mode 100644
index 000000000..12ab29d53
--- /dev/null
+++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts
@@ -0,0 +1,147 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { MessageType, RealtimeUser } from '@hedgedoc/commons';
+import { Listener } from 'eventemitter2';
+
+import { RealtimeConnection } from './realtime-connection';
+import { RealtimeNote } from './realtime-note';
+
+/**
+ * Saves the current realtime status of a specific client and sends updates of changes to other clients.
+ */
+export class RealtimeUserStatusAdapter {
+  private readonly realtimeUser: RealtimeUser;
+
+  constructor(
+    username: string | null,
+    displayName: string,
+    private connection: RealtimeConnection,
+  ) {
+    this.realtimeUser = this.createInitialRealtimeUserState(
+      username,
+      displayName,
+      connection.getRealtimeNote(),
+    );
+    this.bindRealtimeUserStateEvents(connection);
+  }
+
+  private createInitialRealtimeUserState(
+    username: string | null,
+    displayName: string,
+    realtimeNote: RealtimeNote,
+  ): RealtimeUser {
+    return {
+      username: username,
+      displayName: displayName,
+      active: true,
+      styleIndex: this.findLeastUsedStyleIndex(
+        this.createStyleIndexToCountMap(realtimeNote),
+      ),
+      cursor: {
+        from: 0,
+        to: 0,
+      },
+    };
+  }
+
+  private bindRealtimeUserStateEvents(connection: RealtimeConnection): void {
+    const realtimeNote = connection.getRealtimeNote();
+    const transporterMessagesListener = connection.getTransporter().on(
+      MessageType.REALTIME_USER_SINGLE_UPDATE,
+      (message) => {
+        this.realtimeUser.cursor = message.payload;
+        this.sendRealtimeUserStatusUpdateEvent(connection);
+      },
+      { objectify: true },
+    ) as Listener;
+
+    const transporterRequestMessageListener = connection.getTransporter().on(
+      MessageType.REALTIME_USER_STATE_REQUEST,
+      () => {
+        this.sendCompleteStateToClient(connection);
+      },
+      { objectify: true },
+    ) as Listener;
+
+    const clientRemoveListener = realtimeNote.on(
+      'clientRemoved',
+      (client: RealtimeConnection) => {
+        if (client === connection) {
+          this.sendRealtimeUserStatusUpdateEvent(connection);
+        }
+      },
+      {
+        objectify: true,
+      },
+    ) as Listener;
+
+    connection.getTransporter().on('disconnected', () => {
+      transporterMessagesListener.off();
+      transporterRequestMessageListener.off();
+      clientRemoveListener.off();
+    });
+  }
+
+  private sendRealtimeUserStatusUpdateEvent(
+    exceptClient: RealtimeConnection,
+  ): void {
+    this.collectAllConnectionsExcept(exceptClient).forEach(
+      this.sendCompleteStateToClient.bind(this),
+    );
+  }
+
+  private sendCompleteStateToClient(client: RealtimeConnection): void {
+    const payload = this.collectAllConnectionsExcept(client).map(
+      (client) => client.getRealtimeUserStateAdapter().realtimeUser,
+    );
+
+    client.getTransporter().sendMessage({
+      type: MessageType.REALTIME_USER_STATE_SET,
+      payload,
+    });
+  }
+
+  private collectAllConnectionsExcept(
+    exceptClient: RealtimeConnection,
+  ): RealtimeConnection[] {
+    return this.connection
+      .getRealtimeNote()
+      .getConnections()
+      .filter(
+        (client) =>
+          client !== exceptClient && client.getTransporter().isReady(),
+      );
+  }
+
+  private findLeastUsedStyleIndex(map: Map<number, number>): number {
+    let leastUsedStyleIndex = 0;
+    let leastUsedStyleIndexCount = map.get(0) ?? 0;
+    for (let styleIndex = 0; styleIndex < 8; styleIndex++) {
+      const count = map.get(styleIndex) ?? 0;
+      if (count < leastUsedStyleIndexCount) {
+        leastUsedStyleIndexCount = count;
+        leastUsedStyleIndex = styleIndex;
+      }
+    }
+    return leastUsedStyleIndex;
+  }
+
+  private createStyleIndexToCountMap(
+    realtimeNote: RealtimeNote,
+  ): Map<number, number> {
+    return realtimeNote
+      .getConnections()
+      .map(
+        (connection) =>
+          connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex,
+      )
+      .reduce((map, styleIndex) => {
+        const count = (map.get(styleIndex) ?? 0) + 1;
+        map.set(styleIndex, count);
+        return map;
+      }, new Map<number, number>());
+  }
+}
diff --git a/backend/src/realtime/realtime-note/test-utils/mock-awareness.ts b/backend/src/realtime/realtime-note/test-utils/mock-awareness.ts
deleted file mode 100644
index d67b36e76..000000000
--- a/backend/src/realtime/realtime-note/test-utils/mock-awareness.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { Observable } from 'lib0/observable';
-import { Mock } from 'ts-mockery';
-
-import { WebsocketAwareness } from '../websocket-awareness';
-
-class MockAwareness extends Observable<string> {
-  destroy(): void {
-    //intentionally left blank
-  }
-}
-
-/**
- * Provides a partial mock for {@link WebsocketAwareness}.
- */
-export function mockAwareness(): WebsocketAwareness {
-  return Mock.from<WebsocketAwareness>(new MockAwareness());
-}
diff --git a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts
index 86810f56e..b64758af0 100644
--- a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts
+++ b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts
@@ -3,21 +3,61 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import {
+  MockedBackendMessageTransporter,
+  YDocSyncServerAdapter,
+} from '@hedgedoc/commons';
 import { Mock } from 'ts-mockery';
 
 import { User } from '../../../users/user.entity';
-import { WebsocketConnection } from '../websocket-connection';
+import { RealtimeConnection } from '../realtime-connection';
+import { RealtimeNote } from '../realtime-note';
+import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter';
 
-/**
- * Provides a partial mock for {@link WebsocketConnection}.
- *
- * @param synced Defines the return value for the `isSynced` function.
- */
-export function mockConnection(synced: boolean): WebsocketConnection {
-  return Mock.of<WebsocketConnection>({
-    isSynced: jest.fn(() => synced),
-    send: jest.fn(),
-    getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })),
-    getUsername: jest.fn(() => 'mocked user'),
-  });
+export class MockConnectionBuilder {
+  private username = 'mock';
+  private includeRealtimeUserState = false;
+
+  constructor(private readonly realtimeNote: RealtimeNote) {}
+
+  public withUsername(username: string): this {
+    this.username = username;
+    return this;
+  }
+
+  public withRealtimeUserState(): this {
+    this.includeRealtimeUserState = true;
+    return this;
+  }
+
+  public build(): RealtimeConnection {
+    const transporter = new MockedBackendMessageTransporter('');
+    let realtimeUserStateAdapter: RealtimeUserStatusAdapter =
+      Mock.of<RealtimeUserStatusAdapter>();
+
+    const connection = Mock.of<RealtimeConnection>({
+      getUser: jest.fn(() => Mock.of<User>({ username: this.username })),
+      getDisplayName: jest.fn(() => this.username),
+      getSyncAdapter: jest.fn(() => Mock.of<YDocSyncServerAdapter>({})),
+      getTransporter: jest.fn(() => transporter),
+      getRealtimeUserStateAdapter: () => realtimeUserStateAdapter,
+      getRealtimeNote: () => this.realtimeNote,
+    });
+
+    transporter.on('disconnected', () =>
+      this.realtimeNote.removeClient(connection),
+    );
+
+    if (this.includeRealtimeUserState) {
+      realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
+        this.username,
+        this.username,
+        connection,
+      );
+    }
+
+    this.realtimeNote.addClient(connection);
+
+    return connection;
+  }
 }
diff --git a/backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts b/backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts
deleted file mode 100644
index ef95809b0..000000000
--- a/backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { EventEmitter2 } from 'eventemitter2';
-import { Mock } from 'ts-mockery';
-
-import { Note } from '../../../notes/note.entity';
-import { MapType, RealtimeNote } from '../realtime-note';
-import { WebsocketAwareness } from '../websocket-awareness';
-import { WebsocketDoc } from '../websocket-doc';
-import { mockAwareness } from './mock-awareness';
-import { mockWebsocketDoc } from './mock-websocket-doc';
-
-class MockRealtimeNote extends EventEmitter2<MapType> {
-  constructor(
-    private note: Note,
-    private doc: WebsocketDoc,
-    private awareness: WebsocketAwareness,
-  ) {
-    super();
-  }
-
-  public getNote(): Note {
-    return this.note;
-  }
-
-  public getYDoc(): WebsocketDoc {
-    return this.doc;
-  }
-
-  public getAwareness(): WebsocketAwareness {
-    return this.awareness;
-  }
-
-  public removeClient(): void {
-    //left blank for mock
-  }
-
-  public destroy(): void {
-    //left blank for mock
-  }
-}
-
-/**
- * Provides a partial mock for {@link RealtimeNote}
- * @param doc Defines the return value for `getYDoc`
- * @param awareness Defines the return value for `getAwareness`
- */
-export function mockRealtimeNote(
-  note?: Note,
-  doc?: WebsocketDoc,
-  awareness?: WebsocketAwareness,
-): RealtimeNote {
-  return Mock.from<RealtimeNote>(
-    new MockRealtimeNote(
-      note ?? Mock.of<Note>(),
-      doc ?? mockWebsocketDoc(),
-      awareness ?? mockAwareness(),
-    ),
-  );
-}
diff --git a/backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts b/backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
deleted file mode 100644
index 1122c56f8..000000000
--- a/backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { Mock } from 'ts-mockery';
-
-import { WebsocketDoc } from '../websocket-doc';
-
-/**
- * Provides a partial mock for {@link WebsocketDoc}.
- */
-export function mockWebsocketDoc(): WebsocketDoc {
-  return Mock.of<WebsocketDoc>({
-    on: jest.fn(),
-    destroy: jest.fn(),
-    getCurrentContent: jest.fn(),
-  });
-}
diff --git a/backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts b/backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts
deleted file mode 100644
index b5ea303f6..000000000
--- a/backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { WebsocketTransporter } from '@hedgedoc/commons';
-import { EventEmitter2 } from 'eventemitter2';
-import { Mock } from 'ts-mockery';
-
-class MockMessageTransporter extends EventEmitter2 {
-  setupWebsocket(): void {
-    //intentionally left blank
-  }
-
-  send(): void {
-    //intentionally left blank
-  }
-
-  isSynced(): boolean {
-    return false;
-  }
-
-  disconnect(): void {
-    //intentionally left blank
-  }
-}
-
-/**
- * Provides a partial mock for {@link WebsocketTransporter}.
- */
-export function mockWebsocketTransporter(): WebsocketTransporter {
-  return Mock.from<WebsocketTransporter>(new MockMessageTransporter());
-}
diff --git a/backend/src/realtime/realtime-note/websocket-awareness.spec.ts b/backend/src/realtime/realtime-note/websocket-awareness.spec.ts
deleted file mode 100644
index e27c7f917..000000000
--- a/backend/src/realtime/realtime-note/websocket-awareness.spec.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import * as hedgedocRealtimeModule from '@hedgedoc/commons';
-import { Mock } from 'ts-mockery';
-
-import { RealtimeNote } from './realtime-note';
-import { mockConnection } from './test-utils/mock-connection';
-import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
-import { WebsocketConnection } from './websocket-connection';
-import { WebsocketDoc } from './websocket-doc';
-
-jest.mock('@hedgedoc/commons');
-
-describe('websocket-awareness', () => {
-  it('distributes content updates to other synced clients', () => {
-    const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
-    const mockedEncodeUpdateFunction = jest.spyOn(
-      hedgedocRealtimeModule,
-      'encodeAwarenessUpdateMessage',
-    );
-    mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
-
-    const mockConnection1 = mockConnection(true);
-    const mockConnection2 = mockConnection(false);
-    const mockConnection3 = mockConnection(true);
-    const send1 = jest.spyOn(mockConnection1, 'send');
-    const send2 = jest.spyOn(mockConnection2, 'send');
-    const send3 = jest.spyOn(mockConnection3, 'send');
-
-    const realtimeNote = Mock.of<RealtimeNote>({
-      getYDoc(): WebsocketDoc {
-        return Mock.of<WebsocketDoc>({
-          on() {
-            //mocked
-          },
-        });
-      },
-      getConnections(): WebsocketConnection[] {
-        return [mockConnection1, mockConnection2, mockConnection3];
-      },
-    });
-
-    const websocketAwareness = new WebsocketAwareness(realtimeNote);
-    const mockUpdate: ClientIdUpdate = {
-      added: [1],
-      updated: [2],
-      removed: [3],
-    };
-    websocketAwareness.emit('update', [mockUpdate, mockConnection1]);
-    expect(send1).not.toHaveBeenCalled();
-    expect(send2).not.toHaveBeenCalled();
-    expect(send3).toHaveBeenCalledWith(mockEncodedUpdate);
-    expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(
-      websocketAwareness,
-      [1, 2, 3],
-    );
-    websocketAwareness.destroy();
-  });
-});
diff --git a/backend/src/realtime/realtime-note/websocket-awareness.ts b/backend/src/realtime/realtime-note/websocket-awareness.ts
deleted file mode 100644
index a9d0a963c..000000000
--- a/backend/src/realtime/realtime-note/websocket-awareness.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeAwarenessUpdateMessage } from '@hedgedoc/commons';
-import { Awareness } from 'y-protocols/awareness';
-
-import { RealtimeNote } from './realtime-note';
-
-export interface ClientIdUpdate {
-  added: number[];
-  updated: number[];
-  removed: number[];
-}
-
-/**
- * This is the implementation of {@link Awareness YAwareness} which includes additional handlers for message sending and receiving.
- */
-export class WebsocketAwareness extends Awareness {
-  constructor(private realtimeNote: RealtimeNote) {
-    super(realtimeNote.getYDoc());
-    this.setLocalState(null);
-    this.on('update', this.distributeAwarenessUpdate.bind(this));
-  }
-
-  /**
-   * Distributes the given awareness changes to all clients.
-   *
-   * @param added Properties that were added to the awareness state
-   * @param updated Properties that were updated in the awareness state
-   * @param removed Properties that were removed from the awareness state
-   * @param origin An object that is used as reference for the origin of the update
-   */
-  private distributeAwarenessUpdate(
-    { added, updated, removed }: ClientIdUpdate,
-    origin: unknown,
-  ): void {
-    const binaryUpdate = encodeAwarenessUpdateMessage(this, [
-      ...added,
-      ...updated,
-      ...removed,
-    ]);
-    this.realtimeNote
-      .getConnections()
-      .filter((client) => client !== origin && client.isSynced())
-      .forEach((client) => client.send(binaryUpdate));
-  }
-}
diff --git a/backend/src/realtime/realtime-note/websocket-connection.spec.ts b/backend/src/realtime/realtime-note/websocket-connection.spec.ts
deleted file mode 100644
index eac4155f4..000000000
--- a/backend/src/realtime/realtime-note/websocket-connection.spec.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import * as hedgedocRealtimeModule from '@hedgedoc/commons';
-import { WebsocketTransporter } from '@hedgedoc/commons';
-import { Mock } from 'ts-mockery';
-import WebSocket from 'ws';
-import * as yProtocolsAwarenessModule from 'y-protocols/awareness';
-
-import { Note } from '../../notes/note.entity';
-import { User } from '../../users/user.entity';
-import * as realtimeNoteModule from './realtime-note';
-import { RealtimeNote } from './realtime-note';
-import { mockAwareness } from './test-utils/mock-awareness';
-import { mockRealtimeNote } from './test-utils/mock-realtime-note';
-import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
-import { mockWebsocketTransporter } from './test-utils/mock-websocket-transporter';
-import * as websocketAwarenessModule from './websocket-awareness';
-import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
-import { WebsocketConnection } from './websocket-connection';
-import * as websocketDocModule from './websocket-doc';
-import { WebsocketDoc } from './websocket-doc';
-
-import SpyInstance = jest.SpyInstance;
-
-jest.mock('@hedgedoc/commons');
-
-describe('websocket connection', () => {
-  let mockedDoc: WebsocketDoc;
-  let mockedAwareness: WebsocketAwareness;
-  let mockedRealtimeNote: RealtimeNote;
-  let mockedWebsocket: WebSocket;
-  let mockedUser: User;
-  let mockedWebsocketTransporter: WebsocketTransporter;
-  let removeAwarenessSpy: SpyInstance;
-
-  beforeEach(() => {
-    jest.resetAllMocks();
-    jest.resetModules();
-    mockedDoc = mockWebsocketDoc();
-    mockedAwareness = mockAwareness();
-    mockedRealtimeNote = mockRealtimeNote(
-      Mock.of<Note>(),
-      mockedDoc,
-      mockedAwareness,
-    );
-    mockedWebsocket = Mock.of<WebSocket>({});
-    mockedUser = Mock.of<User>({});
-    mockedWebsocketTransporter = mockWebsocketTransporter();
-
-    jest
-      .spyOn(realtimeNoteModule, 'RealtimeNote')
-      .mockImplementation(() => mockedRealtimeNote);
-    jest
-      .spyOn(websocketDocModule, 'WebsocketDoc')
-      .mockImplementation(() => mockedDoc);
-    jest
-      .spyOn(websocketAwarenessModule, 'WebsocketAwareness')
-      .mockImplementation(() => mockedAwareness);
-    jest
-      .spyOn(hedgedocRealtimeModule, 'WebsocketTransporter')
-      .mockImplementation(() => mockedWebsocketTransporter);
-
-    removeAwarenessSpy = jest
-      .spyOn(yProtocolsAwarenessModule, 'removeAwarenessStates')
-      .mockImplementation();
-  });
-
-  afterAll(() => {
-    jest.resetAllMocks();
-    jest.resetModules();
-  });
-
-  it('sets up the websocket in the constructor', () => {
-    const setupWebsocketSpy = jest.spyOn(
-      mockedWebsocketTransporter,
-      'setupWebsocket',
-    );
-
-    new WebsocketConnection(mockedWebsocket, mockedUser, mockedRealtimeNote);
-
-    expect(setupWebsocketSpy).toHaveBeenCalledWith(mockedWebsocket);
-  });
-
-  it('forwards sent messages to the transporter', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const sendFunctionSpy = jest.spyOn(mockedWebsocketTransporter, 'send');
-    const sendContent = new Uint8Array();
-    sut.send(sendContent);
-    expect(sendFunctionSpy).toHaveBeenCalledWith(sendContent);
-  });
-
-  it('forwards disconnect calls to the transporter', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const disconnectFunctionSpy = jest.spyOn(
-      mockedWebsocketTransporter,
-      'disconnect',
-    );
-    sut.disconnect();
-    expect(disconnectFunctionSpy).toHaveBeenCalled();
-  });
-
-  it('forwards isSynced checks to the transporter', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const isSyncedFunctionSpy = jest.spyOn(
-      mockedWebsocketTransporter,
-      'isSynced',
-    );
-
-    expect(sut.isSynced()).toBe(false);
-
-    isSyncedFunctionSpy.mockReturnValue(true);
-    expect(sut.isSynced()).toBe(true);
-  });
-
-  it('removes the client from the note on transporter disconnect', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
-
-    mockedWebsocketTransporter.emit('disconnected');
-
-    expect(removeClientSpy).toHaveBeenCalledWith(sut);
-  });
-
-  it('remembers the controlled awareness-ids on awareness update', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
-    mockedAwareness.emit('update', [update, sut]);
-
-    expect(sut.getControlledAwarenessIds()).toEqual(new Set([0]));
-  });
-
-  it("doesn't remembers the controlled awareness-ids of other connections on awareness update", () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
-    mockedAwareness.emit('update', [update, Mock.of<WebsocketConnection>()]);
-
-    expect(sut.getControlledAwarenessIds()).toEqual(new Set([]));
-  });
-
-  it('removes the controlled awareness ids on transport disconnect', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
-    mockedAwareness.emit('update', [update, sut]);
-
-    mockedWebsocketTransporter.emit('disconnected');
-
-    expect(removeAwarenessSpy).toHaveBeenCalledWith(mockedAwareness, [0], sut);
-  });
-
-  it('saves the correct user', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    expect(sut.getUser()).toBe(mockedUser);
-  });
-
-  it('returns the correct username', () => {
-    const mockedUserWithUsername = Mock.of<User>({ username: 'MockUser' });
-
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUserWithUsername,
-      mockedRealtimeNote,
-    );
-
-    expect(sut.getUsername()).toBe('MockUser');
-  });
-
-  it('returns a fallback if no username has been set', () => {
-    const sut = new WebsocketConnection(
-      mockedWebsocket,
-      mockedUser,
-      mockedRealtimeNote,
-    );
-
-    expect(sut.getUsername()).toBe('Guest');
-  });
-});
diff --git a/backend/src/realtime/realtime-note/websocket-connection.ts b/backend/src/realtime/realtime-note/websocket-connection.ts
deleted file mode 100644
index 1058d53fe..000000000
--- a/backend/src/realtime/realtime-note/websocket-connection.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { WebsocketTransporter } from '@hedgedoc/commons';
-import { Logger } from '@nestjs/common';
-import WebSocket from 'ws';
-import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
-
-import { User } from '../../users/user.entity';
-import { RealtimeNote } from './realtime-note';
-import { ClientIdUpdate } from './websocket-awareness';
-
-/**
- * Manages the websocket connection to a specific client.
- */
-export class WebsocketConnection {
-  protected readonly logger = new Logger(WebsocketConnection.name);
-  private controlledAwarenessIds: Set<number> = new Set();
-  private transporter: WebsocketTransporter;
-
-  /**
-   * Instantiates the websocket connection wrapper for a websocket connection.
-   *
-   * @param websocket The client's raw websocket.
-   * @param user The user of the client
-   * @param realtimeNote The {@link RealtimeNote} that the client connected to.
-   * @throws Error if the socket is not open
-   */
-  constructor(
-    websocket: WebSocket,
-    private user: User | null,
-    realtimeNote: RealtimeNote,
-  ) {
-    const awareness = realtimeNote.getAwareness();
-    this.transporter = new WebsocketTransporter(
-      realtimeNote.getYDoc(),
-      awareness,
-    );
-    this.transporter.on('disconnected', () => {
-      realtimeNote.removeClient(this);
-    });
-    this.transporter.setupWebsocket(websocket);
-    this.bindAwarenessMessageEvents(awareness);
-  }
-
-  /**
-   * Binds all additional events that are needed for awareness processing.
-   */
-  private bindAwarenessMessageEvents(awareness: Awareness): void {
-    const callback = this.updateControlledAwarenessIds.bind(this);
-    awareness.on('update', callback);
-    this.transporter.on('disconnected', () => {
-      awareness.off('update', callback);
-      removeAwarenessStates(awareness, [...this.controlledAwarenessIds], this);
-    });
-  }
-
-  private updateControlledAwarenessIds(
-    { added, removed }: ClientIdUpdate,
-    origin: WebsocketConnection,
-  ): void {
-    if (origin === this) {
-      added.forEach((id) => this.controlledAwarenessIds.add(id));
-      removed.forEach((id) => this.controlledAwarenessIds.delete(id));
-    }
-  }
-
-  /**
-   * Defines if the current connection has received at least one full synchronisation.
-   */
-  public isSynced(): boolean {
-    return this.transporter.isSynced();
-  }
-
-  /**
-   * Sends the given content to the client.
-   *
-   * @param content The content to send
-   */
-  public send(content: Uint8Array): void {
-    this.transporter.send(content);
-  }
-
-  /**
-   * Stops the connection
-   */
-  public disconnect(): void {
-    this.transporter.disconnect();
-  }
-
-  public getControlledAwarenessIds(): ReadonlySet<number> {
-    return this.controlledAwarenessIds;
-  }
-
-  public getUser(): User | null {
-    return this.user;
-  }
-
-  public getUsername(): string {
-    return this.getUser()?.username ?? 'Guest';
-  }
-}
diff --git a/backend/src/realtime/realtime-note/websocket-doc.spec.ts b/backend/src/realtime/realtime-note/websocket-doc.spec.ts
deleted file mode 100644
index 7458e2840..000000000
--- a/backend/src/realtime/realtime-note/websocket-doc.spec.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import * as hedgedocRealtimeModule from '@hedgedoc/commons';
-import { Mock } from 'ts-mockery';
-
-import { RealtimeNote } from './realtime-note';
-import { mockConnection } from './test-utils/mock-connection';
-import { WebsocketConnection } from './websocket-connection';
-import { WebsocketDoc } from './websocket-doc';
-
-jest.mock('@hedgedoc/commons');
-
-describe('websocket-doc', () => {
-  it('saves the initial content', () => {
-    const textContent = 'textContent';
-    const websocketDoc = new WebsocketDoc(Mock.of<RealtimeNote>(), textContent);
-
-    expect(websocketDoc.getCurrentContent()).toBe(textContent);
-  });
-
-  it('distributes content updates to other synced clients', () => {
-    const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
-    const mockedEncodeUpdateFunction = jest.spyOn(
-      hedgedocRealtimeModule,
-      'encodeDocumentUpdateMessage',
-    );
-    mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
-
-    const mockConnection1 = mockConnection(true);
-    const mockConnection2 = mockConnection(false);
-    const mockConnection3 = mockConnection(true);
-
-    const send1 = jest.spyOn(mockConnection1, 'send');
-    const send2 = jest.spyOn(mockConnection2, 'send');
-    const send3 = jest.spyOn(mockConnection3, 'send');
-
-    const realtimeNote = Mock.of<RealtimeNote>({
-      getConnections(): WebsocketConnection[] {
-        return [mockConnection1, mockConnection2, mockConnection3];
-      },
-      getYDoc(): WebsocketDoc {
-        return websocketDoc;
-      },
-    });
-
-    const websocketDoc = new WebsocketDoc(realtimeNote, '');
-    const mockUpdate = new Uint8Array([4, 5, 6, 7]);
-    websocketDoc.emit('update', [mockUpdate, mockConnection1]);
-    expect(send1).not.toHaveBeenCalled();
-    expect(send2).not.toHaveBeenCalled();
-    expect(send3).toHaveBeenCalledWith(mockEncodedUpdate);
-    expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(mockUpdate);
-    websocketDoc.destroy();
-  });
-});
diff --git a/backend/src/realtime/realtime-note/websocket-doc.ts b/backend/src/realtime/realtime-note/websocket-doc.ts
deleted file mode 100644
index 9f515fef3..000000000
--- a/backend/src/realtime/realtime-note/websocket-doc.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import {
-  encodeDocumentUpdateMessage,
-  MARKDOWN_CONTENT_CHANNEL_NAME,
-} from '@hedgedoc/commons';
-import { Doc } from 'yjs';
-
-import { RealtimeNote } from './realtime-note';
-import { WebsocketConnection } from './websocket-connection';
-
-/**
- * This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
- */
-export class WebsocketDoc extends Doc {
-  /**
-   * Creates a new WebsocketDoc instance.
-   *
-   * The new instance is filled with the given initial content and an event listener will be registered to handle
-   * updates to the doc.
-   *
-   * @param realtimeNote - the {@link RealtimeNote} handling this {@link Doc YDoc}
-   * @param initialContent - the initial content of the {@link Doc YDoc}
-   */
-  constructor(private realtimeNote: RealtimeNote, initialContent: string) {
-    super();
-    this.initializeContent(initialContent);
-    this.bindUpdateEvent();
-  }
-
-  /**
-   * Binds the event that distributes updates in the current {@link Doc y-doc} to all clients.
-   */
-  private bindUpdateEvent(): void {
-    this.on('update', (update: Uint8Array, origin: WebsocketConnection) => {
-      const clients = this.realtimeNote
-        .getConnections()
-        .filter((client) => client !== origin && client.isSynced());
-      if (clients.length > 0) {
-        clients.forEach((client) => {
-          client.send(encodeDocumentUpdateMessage(update));
-        });
-      }
-    });
-  }
-
-  /**
-   * Sets the {@link YDoc's Doc} content to include the initialContent.
-   *
-   * This message should only be called when a new {@link RealtimeNote } is created.
-   *
-   * @param initialContent - the initial content to set the {@link Doc YDoc's} content to.
-   * @private
-   */
-  private initializeContent(initialContent: string): void {
-    this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).insert(0, initialContent);
-  }
-
-  /**
-   * Gets the current content of the note as it's currently edited in realtime.
-   *
-   * Please be aware that the return of this method may be very quickly outdated.
-   *
-   * @return The current note content.
-   */
-  public getCurrentContent(): string {
-    return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).toString();
-  }
-}
diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts
index acd205510..2f5ab0d8c 100644
--- a/backend/src/realtime/websocket/websocket.gateway.spec.ts
+++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts
@@ -40,15 +40,15 @@ import { Session } from '../../users/session.entity';
 import { User } from '../../users/user.entity';
 import { UsersModule } from '../../users/users.module';
 import { UsersService } from '../../users/users.service';
+import * as websocketConnectionModule from '../realtime-note/realtime-connection';
+import { RealtimeConnection } from '../realtime-note/realtime-connection';
 import { RealtimeNote } from '../realtime-note/realtime-note';
 import { RealtimeNoteModule } from '../realtime-note/realtime-note.module';
 import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
-import * as websocketConnectionModule from '../realtime-note/websocket-connection';
-import { WebsocketConnection } from '../realtime-note/websocket-connection';
 import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url';
 import { WebsocketGateway } from './websocket.gateway';
 
-import SpyInstance = jest.SpyInstance;
+jest.mock('@hedgedoc/commons');
 
 describe('Websocket gateway', () => {
   let gateway: WebsocketGateway;
@@ -57,10 +57,10 @@ describe('Websocket gateway', () => {
   let notesService: NotesService;
   let realtimeNoteService: RealtimeNoteService;
   let permissionsService: PermissionsService;
-  let mockedWebsocketConnection: WebsocketConnection;
+  let mockedWebsocketConnection: RealtimeConnection;
   let mockedWebsocket: WebSocket;
-  let mockedWebsocketCloseSpy: SpyInstance;
-  let addClientSpy: SpyInstance;
+  let mockedWebsocketCloseSpy: jest.SpyInstance;
+  let addClientSpy: jest.SpyInstance;
 
   const mockedValidSessionCookie = 'mockedValidSessionCookie';
   const mockedSessionIdWithUser = 'mockedSessionIdWithUser';
@@ -231,9 +231,9 @@ describe('Websocket gateway', () => {
       .spyOn(realtimeNoteService, 'getOrCreateRealtimeNote')
       .mockReturnValue(Promise.resolve(mockedRealtimeNote));
 
-    mockedWebsocketConnection = Mock.of<WebsocketConnection>();
+    mockedWebsocketConnection = Mock.of<RealtimeConnection>();
     jest
-      .spyOn(websocketConnectionModule, 'WebsocketConnection')
+      .spyOn(websocketConnectionModule, 'RealtimeConnection')
       .mockReturnValue(mockedWebsocketConnection);
 
     mockedWebsocket = Mock.of<WebSocket>({
diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts
index 10c9c225a..48ad651ac 100644
--- a/backend/src/realtime/websocket/websocket.gateway.ts
+++ b/backend/src/realtime/websocket/websocket.gateway.ts
@@ -3,6 +3,7 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import { WebsocketTransporter } from '@hedgedoc/commons';
 import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
 import { IncomingMessage } from 'http';
 import WebSocket from 'ws';
@@ -13,8 +14,8 @@ import { PermissionsService } from '../../permissions/permissions.service';
 import { SessionService } from '../../session/session.service';
 import { User } from '../../users/user.entity';
 import { UsersService } from '../../users/users.service';
+import { RealtimeConnection } from '../realtime-note/realtime-connection';
 import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
-import { WebsocketConnection } from '../realtime-note/websocket-connection';
 import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url';
 
 /**
@@ -75,13 +76,17 @@ export class WebsocketGateway implements OnGatewayConnection {
       const realtimeNote =
         await this.realtimeNoteService.getOrCreateRealtimeNote(note);
 
-      const connection = new WebsocketConnection(
-        clientSocket,
+      const websocketTransporter = new WebsocketTransporter();
+      const connection = new RealtimeConnection(
+        websocketTransporter,
         user,
         realtimeNote,
       );
+      websocketTransporter.setWebsocket(clientSocket);
 
       realtimeNote.addClient(connection);
+
+      websocketTransporter.sendReady();
     } catch (error: unknown) {
       this.logger.error(
         `Error occurred while initializing: ${(error as Error).message}`,
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
index c584ce5ef..3704bce5f 100644
--- a/backend/tsconfig.json
+++ b/backend/tsconfig.json
@@ -11,6 +11,7 @@
     "baseUrl": "./",
     "incremental": true,
     "strict": true,
-    "strictPropertyInitialization": false
+    "strictPropertyInitialization": false,
+    "resolveJsonModule": true
   }
 }
diff --git a/commons/package.json b/commons/package.json
index 9eb5017e2..c5153e10b 100644
--- a/commons/package.json
+++ b/commons/package.json
@@ -30,6 +30,9 @@
     "README.md",
     "dist/**"
   ],
+  "browserslist": [
+    "node> 12"
+  ],
   "repository": {
     "type": "git",
     "url": "https://github.com/hedgedoc/hedgedoc.git"
@@ -37,9 +40,7 @@
   "dependencies": {
     "eventemitter2": "6.4.9",
     "isomorphic-ws": "5.0.0",
-    "lib0": "0.2.73",
     "ws": "8.13.0",
-    "y-protocols": "1.0.5",
     "yjs": "13.5.51"
   },
   "devDependencies": {
diff --git a/commons/src/connection-keep-alive-handler.ts b/commons/src/connection-keep-alive-handler.ts
deleted file mode 100644
index e3b80ff1b..000000000
--- a/commons/src/connection-keep-alive-handler.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MessageType } from './messages/message-type.enum.js'
-import type { YDocMessageTransporter } from './y-doc-message-transporter.js'
-import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding'
-
-/**
- * Provides a keep alive ping for a given {@link WebSocket websocket} connection by sending a periodic message.
- */
-export class ConnectionKeepAliveHandler {
-  private pongReceived = false
-  private static readonly pingTimeout = 30 * 1000
-  private intervalId: NodeJS.Timer | undefined
-
-  /**
-   * Constructs the instance and starts the interval.
-   *
-   * @param messageTransporter The websocket to keep alive
-   */
-  constructor(private messageTransporter: YDocMessageTransporter) {
-    this.messageTransporter.on('disconnected', () => this.stopTimer())
-    this.messageTransporter.on('ready', () => this.startTimer())
-    this.messageTransporter.on(String(MessageType.PING), () => {
-      this.sendPongMessage()
-    })
-    this.messageTransporter.on(
-      String(MessageType.PONG),
-      () => (this.pongReceived = true)
-    )
-  }
-
-  /**
-   * Starts the ping timer.
-   */
-  public startTimer(): void {
-    this.pongReceived = false
-    this.intervalId = setInterval(
-      () => this.check(),
-      ConnectionKeepAliveHandler.pingTimeout
-    )
-    this.sendPingMessage()
-  }
-
-  public stopTimer(): void {
-    clearInterval(this.intervalId)
-  }
-
-  /**
-   * Checks if a pong has been received since the last run. If not, the connection is probably dead and will be terminated.
-   */
-  private check(): void {
-    if (this.pongReceived) {
-      this.pongReceived = false
-      this.sendPingMessage()
-    } else {
-      this.messageTransporter.disconnect()
-      console.error(
-        `No pong received in the last ${ConnectionKeepAliveHandler.pingTimeout} seconds. Connection seems to be dead.`
-      )
-    }
-  }
-
-  private sendPingMessage(): void {
-    const encoder = createEncoder()
-    writeVarUint(encoder, MessageType.PING)
-    this.messageTransporter.send(toUint8Array(encoder))
-  }
-
-  private sendPongMessage(): void {
-    const encoder = createEncoder()
-    writeVarUint(encoder, MessageType.PONG)
-    this.messageTransporter.send(toUint8Array(encoder))
-  }
-}
diff --git a/commons/src/constants/markdown-content-channel-name.ts b/commons/src/constants/markdown-content-channel-name.ts
deleted file mode 100644
index 7b36c2a92..000000000
--- a/commons/src/constants/markdown-content-channel-name.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'
diff --git a/commons/src/index.ts b/commons/src/index.ts
index 2f2d63b45..cf60bf063 100644
--- a/commons/src/index.ts
+++ b/commons/src/index.ts
@@ -1,28 +1,14 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-export { MessageType } from './messages/message-type.enum.js'
-export { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js'
-export { YDocMessageTransporter } from './y-doc-message-transporter.js'
-export {
-  applyAwarenessUpdateMessage,
-  encodeAwarenessUpdateMessage
-} from './messages/awareness-update-message.js'
-export {
-  applyDocumentUpdateMessage,
-  encodeDocumentUpdateMessage
-} from './messages/document-update-message.js'
-export { encodeCompleteAwarenessStateRequestMessage } from './messages/complete-awareness-state-request-message.js'
-export { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
-export { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
-export { encodeDocumentDeletedMessage } from './messages/document-deleted-message.js'
-export { encodeMetadataUpdatedMessage } from './messages/metadata-updated-message.js'
-export { encodeServerVersionUpdatedMessage } from './messages/server-version-updated-message.js'
-
-export { WebsocketTransporter } from './websocket-transporter.js'
+export * from './message-transporters/mocked-backend-message-transporter.js'
+export * from './message-transporters/message.js'
+export * from './message-transporters/message-transporter.js'
+export * from './message-transporters/realtime-user.js'
+export * from './message-transporters/websocket-transporter.js'
 
 export { parseUrl } from './utils/parse-url.js'
 export {
@@ -30,8 +16,10 @@ export {
   WrongProtocolError
 } from './utils/errors.js'
 
-export type { MessageTransporterEvents } from './y-doc-message-transporter.js'
+export * from './y-doc-sync/y-doc-sync-client-adapter.js'
+export * from './y-doc-sync/y-doc-sync-server-adapter.js'
+export * from './y-doc-sync/y-doc-sync-adapter.js'
 
 export { waitForOtherPromisesToFinish } from './utils/wait-for-other-promises-to-finish.js'
 
-export { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js'
+export { RealtimeDoc } from './y-doc-sync/realtime-doc'
diff --git a/commons/src/message-transporters/message-transporter.ts b/commons/src/message-transporters/message-transporter.ts
new file mode 100644
index 000000000..7b0b0d03c
--- /dev/null
+++ b/commons/src/message-transporters/message-transporter.ts
@@ -0,0 +1,102 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Message, MessagePayloads, MessageType } from './message.js'
+import { EventEmitter2, Listener } from 'eventemitter2'
+
+export type MessageEvents = MessageType | 'connected' | 'disconnected'
+
+type MessageEventPayloadMap = {
+  [E in MessageEvents]: E extends keyof MessagePayloads
+    ? (message: Message<E>) => void
+    : () => void
+}
+
+export enum ConnectionState {
+  DISCONNECT,
+  CONNECTING,
+  CONNECTED
+}
+
+/**
+ * Base class for event based message communication.
+ */
+export abstract class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
+  private readyMessageReceived = false
+
+  public abstract sendMessage<M extends MessageType>(content: Message<M>): void
+
+  protected receiveMessage<L extends MessageType>(message: Message<L>): void {
+    if (message.type === MessageType.READY) {
+      this.readyMessageReceived = true
+    }
+    this.emit(message.type, message)
+  }
+
+  public sendReady(): void {
+    this.sendMessage({
+      type: MessageType.READY
+    })
+  }
+
+  public abstract disconnect(): void
+
+  public abstract getConnectionState(): ConnectionState
+
+  protected onConnected(): void {
+    this.emit('connected')
+  }
+
+  protected onDisconnecting(): void {
+    this.readyMessageReceived = false
+    this.emit('disconnected')
+  }
+
+  /**
+   * Indicates if the message transporter is connected and can send/receive messages.
+   */
+  public isConnected(): boolean {
+    return this.getConnectionState() === ConnectionState.CONNECTED
+  }
+
+  /**
+   * Indicates if the message transporter has receives a {@link MessageType.READY ready message} yet.
+   */
+  public isReady(): boolean {
+    return this.readyMessageReceived
+  }
+
+  /**
+   * Executes the given callback whenever the message transporter receives a ready message.
+   * If the messenger has already received a ready message then the callback will be executed immediately.
+   *
+   * @param callback The callback to execute when ready
+   * @return The event listener that waits for ready messages
+   */
+  public doAsSoonAsReady(callback: () => void): Listener {
+    if (this.readyMessageReceived) {
+      callback()
+    }
+    return this.on(MessageType.READY, callback, {
+      objectify: true
+    }) as Listener
+  }
+
+  /**
+   * Executes the given callback whenever the message transporter has established a connection.
+   * If the messenger is already connected then the callback will be executed immediately.
+   *
+   * @param callback The callback to execute when connected
+   * @return The event listener that waits for connection events
+   */
+  public doAsSoonAsConnected(callback: () => void): Listener {
+    if (this.isConnected()) {
+      callback()
+    }
+    return this.on('connected', callback, {
+      objectify: true
+    }) as Listener
+  }
+}
diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts
new file mode 100644
index 000000000..5ddfe1a03
--- /dev/null
+++ b/commons/src/message-transporters/message.ts
@@ -0,0 +1,36 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { RealtimeUser, RemoteCursor } from './realtime-user.js'
+
+export enum MessageType {
+  NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST',
+  NOTE_CONTENT_UPDATE = 'NOTE_CONTENT_UPDATE',
+  PING = 'PING',
+  PONG = 'PONG',
+  METADATA_UPDATED = 'METADATA_UPDATED',
+  DOCUMENT_DELETED = 'DOCUMENT_DELETED',
+  SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED',
+  REALTIME_USER_STATE_SET = 'REALTIME_USER_STATE_SET',
+  REALTIME_USER_SINGLE_UPDATE = 'REALTIME_USER_SINGLE_UPDATE',
+  REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST',
+  READY = 'READY'
+}
+
+export interface MessagePayloads {
+  [MessageType.NOTE_CONTENT_STATE_REQUEST]: number[]
+  [MessageType.NOTE_CONTENT_UPDATE]: number[]
+  [MessageType.REALTIME_USER_STATE_SET]: RealtimeUser[]
+  [MessageType.REALTIME_USER_SINGLE_UPDATE]: RemoteCursor
+}
+
+export type Message<T extends MessageType> = T extends keyof MessagePayloads
+  ? {
+      type: T
+      payload: MessagePayloads[T]
+    }
+  : {
+      type: T
+    }
diff --git a/commons/src/message-transporters/mocked-backend-message-transporter.ts b/commons/src/message-transporters/mocked-backend-message-transporter.ts
new file mode 100644
index 000000000..031da80c0
--- /dev/null
+++ b/commons/src/message-transporters/mocked-backend-message-transporter.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { RealtimeDoc } from '../y-doc-sync/realtime-doc.js'
+import { ConnectionState, MessageTransporter } from './message-transporter.js'
+import { Message, MessageType } from './message.js'
+import { Doc, encodeStateAsUpdate } from 'yjs'
+
+/**
+ * A mocked connection that doesn't send or receive any data and is instantly ready.
+ * The only exception is the note content state request that is answered with the given initial content.
+ */
+export class MockedBackendMessageTransporter extends MessageTransporter {
+  private readonly doc: Doc
+
+  private connected = true
+
+  constructor(initialContent: string) {
+    super()
+    this.doc = new RealtimeDoc(initialContent)
+
+    this.onConnected()
+  }
+
+  disconnect(): void {
+    if (!this.connected) {
+      return
+    }
+    this.connected = false
+    this.onDisconnecting()
+  }
+
+  sendReady() {
+    this.receiveMessage({
+      type: MessageType.READY
+    })
+  }
+
+  sendMessage<M extends MessageType>(content: Message<M>) {
+    if (content.type === MessageType.NOTE_CONTENT_STATE_REQUEST) {
+      setTimeout(() => {
+        const payload = Array.from(
+          encodeStateAsUpdate(this.doc, new Uint8Array(content.payload))
+        )
+        this.receiveMessage({ type: MessageType.NOTE_CONTENT_UPDATE, payload })
+      }, 10)
+    }
+  }
+
+  getConnectionState(): ConnectionState {
+    return this.connected
+      ? ConnectionState.CONNECTED
+      : ConnectionState.DISCONNECT
+  }
+}
diff --git a/commons/src/message-transporters/realtime-user.ts b/commons/src/message-transporters/realtime-user.ts
new file mode 100644
index 000000000..b6d22fe63
--- /dev/null
+++ b/commons/src/message-transporters/realtime-user.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export interface RealtimeUser {
+  displayName: string
+  username: string | null
+  active: boolean
+  styleIndex: number
+  cursor: RemoteCursor
+}
+
+export interface RemoteCursor {
+  from: number
+  to?: number
+}
diff --git a/commons/src/message-transporters/websocket-transporter.ts b/commons/src/message-transporters/websocket-transporter.ts
new file mode 100644
index 000000000..0ed1fdf8a
--- /dev/null
+++ b/commons/src/message-transporters/websocket-transporter.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { ConnectionState, MessageTransporter } from './message-transporter.js'
+import { Message, MessageType } from './message.js'
+import WebSocket, { CloseEvent, ErrorEvent, MessageEvent } from 'isomorphic-ws'
+
+export class WebsocketTransporter extends MessageTransporter {
+  private websocket: WebSocket | undefined
+
+  private messageCallback: undefined | ((event: MessageEvent) => void)
+  private errorCallback: undefined | ((event: ErrorEvent) => void)
+  private closeCallback: undefined | ((event: CloseEvent) => void)
+
+  constructor() {
+    super()
+  }
+
+  public setWebsocket(websocket: WebSocket) {
+    if (
+      websocket.readyState === WebSocket.CLOSED ||
+      websocket.readyState === WebSocket.CLOSING
+    ) {
+      throw new Error('Websocket must be open')
+    }
+    this.undbindEventsFromPreviousWebsocket()
+    this.websocket = websocket
+    this.bindWebsocketEvents(websocket)
+
+    if (this.websocket.readyState === WebSocket.OPEN) {
+      this.onConnected()
+    } else {
+      this.websocket.addEventListener('open', this.onConnected.bind(this))
+    }
+  }
+
+  private undbindEventsFromPreviousWebsocket() {
+    if (this.websocket) {
+      if (this.messageCallback) {
+        this.websocket.removeEventListener('message', this.messageCallback)
+      }
+      if (this.errorCallback) {
+        this.websocket.removeEventListener('error', this.errorCallback)
+      }
+      if (this.closeCallback) {
+        this.websocket.removeEventListener('close', this.closeCallback)
+      }
+    }
+  }
+
+  private bindWebsocketEvents(websocket: WebSocket) {
+    this.messageCallback = this.processMessageEvent.bind(this)
+    this.errorCallback = this.disconnect.bind(this)
+    this.closeCallback = this.onDisconnecting.bind(this)
+
+    websocket.addEventListener('message', this.messageCallback)
+    websocket.addEventListener('error', this.errorCallback)
+    websocket.addEventListener('close', this.closeCallback)
+  }
+
+  private processMessageEvent(event: MessageEvent): void {
+    if (typeof event.data !== 'string') {
+      return
+    }
+    const message = JSON.parse(event.data) as Message<MessageType>
+    this.receiveMessage(message)
+  }
+
+  public disconnect(): void {
+    this.websocket?.close()
+  }
+
+  public sendMessage(content: Message<MessageType>): void {
+    if (this.websocket?.readyState !== WebSocket.OPEN) {
+      throw new Error("Can't send message over non-open socket")
+    }
+
+    try {
+      this.websocket.send(JSON.stringify(content))
+    } catch (error: unknown) {
+      this.disconnect()
+      throw error
+    }
+  }
+
+  public getConnectionState(): ConnectionState {
+    if (this.websocket?.readyState === WebSocket.OPEN) {
+      return ConnectionState.CONNECTED
+    } else if (this.websocket?.readyState === WebSocket.CONNECTING) {
+      return ConnectionState.CONNECTING
+    } else {
+      return ConnectionState.DISCONNECT
+    }
+  }
+}
diff --git a/commons/src/messages/awareness-update-message.ts b/commons/src/messages/awareness-update-message.ts
deleted file mode 100644
index 930964042..000000000
--- a/commons/src/messages/awareness-update-message.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MessageType } from './message-type.enum.js'
-import type { Decoder } from 'lib0/decoding'
-import { readVarUint8Array } from 'lib0/decoding'
-import {
-  createEncoder,
-  toUint8Array,
-  writeVarUint,
-  writeVarUint8Array
-} from 'lib0/encoding'
-import type { Awareness } from 'y-protocols/awareness'
-import {
-  applyAwarenessUpdate,
-  encodeAwarenessUpdate
-} from 'y-protocols/awareness'
-
-export function applyAwarenessUpdateMessage(
-  decoder: Decoder,
-  awareness: Awareness,
-  origin: unknown
-): void {
-  applyAwarenessUpdate(awareness, readVarUint8Array(decoder), origin)
-}
-
-export function encodeAwarenessUpdateMessage(
-  awareness: Awareness,
-  updatedClientIds: number[]
-): Uint8Array {
-  const encoder = createEncoder()
-  writeVarUint(encoder, MessageType.AWARENESS_UPDATE)
-  writeVarUint8Array(
-    encoder,
-    encodeAwarenessUpdate(awareness, updatedClientIds)
-  )
-  return toUint8Array(encoder)
-}
diff --git a/commons/src/messages/complete-awareness-state-request-message.ts b/commons/src/messages/complete-awareness-state-request-message.ts
deleted file mode 100644
index 595133cbc..000000000
--- a/commons/src/messages/complete-awareness-state-request-message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeGenericMessage } from './generic-message.js'
-import { MessageType } from './message-type.enum.js'
-
-export function encodeCompleteAwarenessStateRequestMessage(): Uint8Array {
-  return encodeGenericMessage(MessageType.COMPLETE_AWARENESS_STATE_REQUEST)
-}
diff --git a/commons/src/messages/complete-document-state-answer-message.ts b/commons/src/messages/complete-document-state-answer-message.ts
deleted file mode 100644
index 1809a196c..000000000
--- a/commons/src/messages/complete-document-state-answer-message.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MessageType } from './message-type.enum.js'
-import { decoding } from 'lib0'
-import { Decoder } from 'lib0/decoding'
-import {
-  createEncoder,
-  toUint8Array,
-  writeVarUint,
-  writeVarUint8Array
-} from 'lib0/encoding'
-import type { Doc } from 'yjs'
-import { encodeStateAsUpdate } from 'yjs'
-
-export function encodeCompleteDocumentStateAnswerMessage(
-  doc: Doc,
-  decoder: Decoder
-): Uint8Array {
-  const encoder = createEncoder()
-  writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_ANSWER)
-  writeVarUint8Array(
-    encoder,
-    encodeStateAsUpdate(doc, decoding.readVarUint8Array(decoder))
-  )
-  return toUint8Array(encoder)
-}
diff --git a/commons/src/messages/complete-document-state-request-message.ts b/commons/src/messages/complete-document-state-request-message.ts
deleted file mode 100644
index e01cc5f00..000000000
--- a/commons/src/messages/complete-document-state-request-message.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MessageType } from './message-type.enum.js'
-import {
-  createEncoder,
-  toUint8Array,
-  writeVarUint,
-  writeVarUint8Array
-} from 'lib0/encoding'
-import type { Doc } from 'yjs'
-import { encodeStateVector } from 'yjs'
-
-export function encodeCompleteDocumentStateRequestMessage(
-  doc: Doc
-): Uint8Array {
-  const encoder = createEncoder()
-  writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_REQUEST)
-  writeVarUint8Array(encoder, encodeStateVector(doc))
-  return toUint8Array(encoder)
-}
diff --git a/commons/src/messages/document-deleted-message.ts b/commons/src/messages/document-deleted-message.ts
deleted file mode 100644
index bbdb3d195..000000000
--- a/commons/src/messages/document-deleted-message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeGenericMessage } from './generic-message.js'
-import { MessageType } from './message-type.enum.js'
-
-export function encodeDocumentDeletedMessage(): Uint8Array {
-  return encodeGenericMessage(MessageType.DOCUMENT_DELETED)
-}
diff --git a/commons/src/messages/document-update-message.ts b/commons/src/messages/document-update-message.ts
deleted file mode 100644
index f7b365b8a..000000000
--- a/commons/src/messages/document-update-message.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MessageType } from './message-type.enum.js'
-import { readVarUint8Array } from 'lib0/decoding'
-import type { Decoder } from 'lib0/decoding.js'
-import {
-  createEncoder,
-  toUint8Array,
-  writeVarUint,
-  writeVarUint8Array
-} from 'lib0/encoding'
-import type { Doc } from 'yjs'
-import { applyUpdate } from 'yjs'
-
-export function applyDocumentUpdateMessage(
-  decoder: Decoder,
-  doc: Doc,
-  origin: unknown
-): void {
-  applyUpdate(doc, readVarUint8Array(decoder), origin)
-}
-
-export function encodeDocumentUpdateMessage(
-  documentUpdate: Uint8Array
-): Uint8Array {
-  const encoder = createEncoder()
-  writeVarUint(encoder, MessageType.DOCUMENT_UPDATE)
-  writeVarUint8Array(encoder, documentUpdate)
-  return toUint8Array(encoder)
-}
diff --git a/commons/src/messages/generic-message.ts b/commons/src/messages/generic-message.ts
deleted file mode 100644
index 14ff2de7b..000000000
--- a/commons/src/messages/generic-message.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MessageType } from './message-type.enum.js'
-import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding'
-
-/**
- * Encodes a generic message with a given message type but without content.
- */
-export function encodeGenericMessage(messageType: MessageType): Uint8Array {
-  const encoder = createEncoder()
-  writeVarUint(encoder, messageType)
-  return toUint8Array(encoder)
-}
diff --git a/commons/src/messages/message-type.enum.ts b/commons/src/messages/message-type.enum.ts
deleted file mode 100644
index f0369bd23..000000000
--- a/commons/src/messages/message-type.enum.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export enum MessageType {
-  COMPLETE_DOCUMENT_STATE_REQUEST = 0,
-  COMPLETE_DOCUMENT_STATE_ANSWER = 1,
-  DOCUMENT_UPDATE = 2,
-  AWARENESS_UPDATE = 3,
-  COMPLETE_AWARENESS_STATE_REQUEST = 4,
-  PING = 5,
-  PONG = 6,
-  READY_REQUEST = 7,
-  READY_ANSWER = 8,
-  METADATA_UPDATED = 9,
-  DOCUMENT_DELETED = 10,
-  SERVER_VERSION_UPDATED = 11
-}
diff --git a/commons/src/messages/metadata-updated-message.ts b/commons/src/messages/metadata-updated-message.ts
deleted file mode 100644
index 83d704a62..000000000
--- a/commons/src/messages/metadata-updated-message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeGenericMessage } from './generic-message.js'
-import { MessageType } from './message-type.enum.js'
-
-export function encodeMetadataUpdatedMessage(): Uint8Array {
-  return encodeGenericMessage(MessageType.METADATA_UPDATED)
-}
diff --git a/commons/src/messages/ready-answer-message.ts b/commons/src/messages/ready-answer-message.ts
deleted file mode 100644
index 5edd74680..000000000
--- a/commons/src/messages/ready-answer-message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeGenericMessage } from './generic-message.js'
-import { MessageType } from './message-type.enum.js'
-
-export function encodeReadyAnswerMessage(): Uint8Array {
-  return encodeGenericMessage(MessageType.READY_ANSWER)
-}
diff --git a/commons/src/messages/ready-request-message.ts b/commons/src/messages/ready-request-message.ts
deleted file mode 100644
index 2b1802006..000000000
--- a/commons/src/messages/ready-request-message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeGenericMessage } from './generic-message.js'
-import { MessageType } from './message-type.enum.js'
-
-export function encodeReadyRequestMessage(): Uint8Array {
-  return encodeGenericMessage(MessageType.READY_REQUEST)
-}
diff --git a/commons/src/messages/server-version-updated-message.ts b/commons/src/messages/server-version-updated-message.ts
deleted file mode 100644
index 7b069e228..000000000
--- a/commons/src/messages/server-version-updated-message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { encodeGenericMessage } from './generic-message.js'
-import { MessageType } from './message-type.enum.js'
-
-export function encodeServerVersionUpdatedMessage(): Uint8Array {
-  return encodeGenericMessage(MessageType.SERVER_VERSION_UPDATED)
-}
diff --git a/commons/src/websocket-transporter.ts b/commons/src/websocket-transporter.ts
deleted file mode 100644
index f8dd88a7d..000000000
--- a/commons/src/websocket-transporter.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js'
-import { YDocMessageTransporter } from './y-doc-message-transporter.js'
-import WebSocket from 'isomorphic-ws'
-import { Awareness } from 'y-protocols/awareness'
-import { Doc } from 'yjs'
-
-export class WebsocketTransporter extends YDocMessageTransporter {
-  private websocket: WebSocket | undefined
-
-  constructor(doc: Doc, awareness: Awareness) {
-    super(doc, awareness)
-    new ConnectionKeepAliveHandler(this)
-  }
-
-  public setupWebsocket(websocket: WebSocket) {
-    if (
-      websocket.readyState === WebSocket.CLOSED ||
-      websocket.readyState === WebSocket.CLOSING
-    ) {
-      throw new Error(`Socket is closed`)
-    }
-    this.websocket = websocket
-    websocket.binaryType = 'arraybuffer'
-    websocket.addEventListener('message', (event) =>
-      this.decodeMessage(event.data as ArrayBuffer)
-    )
-    websocket.addEventListener('error', () => this.disconnect())
-    websocket.addEventListener('close', () => this.onClose())
-    if (websocket.readyState === WebSocket.OPEN) {
-      this.onOpen()
-    } else {
-      websocket.addEventListener('open', this.onOpen.bind(this))
-    }
-  }
-
-  public disconnect(): void {
-    this.websocket?.close()
-  }
-
-  public send(content: Uint8Array): void {
-    if (this.websocket?.readyState !== WebSocket.OPEN) {
-      throw new Error("Can't send message over non-open socket")
-    }
-
-    try {
-      this.websocket.send(content)
-    } catch (error: unknown) {
-      this.disconnect()
-      throw error
-    }
-  }
-
-  public isWebSocketOpen(): boolean {
-    return this.websocket?.readyState === WebSocket.OPEN
-  }
-}
diff --git a/commons/src/y-doc-message-transporter.test.ts b/commons/src/y-doc-message-transporter.test.ts
deleted file mode 100644
index aac6a8423..000000000
--- a/commons/src/y-doc-message-transporter.test.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js'
-import { encodeDocumentUpdateMessage } from './messages/document-update-message.js'
-import { MessageType } from './messages/message-type.enum.js'
-import { YDocMessageTransporter } from './y-doc-message-transporter.js'
-import { describe, expect, it } from '@jest/globals'
-import { Awareness } from 'y-protocols/awareness'
-import { Doc } from 'yjs'
-
-class InMemoryMessageTransporter extends YDocMessageTransporter {
-  private otherSide: InMemoryMessageTransporter | undefined
-
-  constructor(private name: string, doc: Doc, awareness: Awareness) {
-    super(doc, awareness)
-  }
-
-  public connect(other: InMemoryMessageTransporter): void {
-    this.setOtherSide(other)
-    other.setOtherSide(this)
-    this.onOpen()
-    other.onOpen()
-  }
-
-  private setOtherSide(other: InMemoryMessageTransporter | undefined): void {
-    this.otherSide = other
-  }
-
-  public disconnect(): void {
-    this.onClose()
-    this.setOtherSide(undefined)
-    this.otherSide?.onClose()
-    this.otherSide?.setOtherSide(undefined)
-  }
-
-  send(content: Uint8Array): void {
-    if (this.otherSide === undefined) {
-      throw new Error('Disconnected')
-    }
-    console.debug(`${this.name}`, 'Sending', content)
-    this.otherSide?.decodeMessage(content)
-  }
-
-  public onOpen(): void {
-    super.onOpen()
-  }
-}
-
-describe('message transporter', () =>
-  it('server client communication', () => {
-    const docServer: Doc = new Doc()
-    const docClient1: Doc = new Doc()
-    const docClient2: Doc = new Doc()
-    const dummyAwareness: Awareness = new Awareness(docServer)
-
-    const textServer = docServer.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
-    const textClient1 = docClient1.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
-    const textClient2 = docClient2.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
-    textServer.insert(0, 'This is a test note')
-
-    textServer.observe(() =>
-      console.debug('textServer', new Date(), textServer.toString())
-    )
-    textClient1.observe(() =>
-      console.debug('textClient1', new Date(), textClient1.toString())
-    )
-    textClient2.observe(() =>
-      console.debug('textClient2', new Date(), textClient2.toString())
-    )
-
-    const transporterServerTo1 = new InMemoryMessageTransporter(
-      's>1',
-      docServer,
-      dummyAwareness
-    )
-    const transporterServerTo2 = new InMemoryMessageTransporter(
-      's>2',
-      docServer,
-      dummyAwareness
-    )
-    const transporterClient1 = new InMemoryMessageTransporter(
-      '1>s',
-      docClient1,
-      dummyAwareness
-    )
-    const transporterClient2 = new InMemoryMessageTransporter(
-      '2>s',
-      docClient2,
-      dummyAwareness
-    )
-
-    transporterServerTo1.on(String(MessageType.DOCUMENT_UPDATE), () =>
-      console.debug('Received DOCUMENT_UPDATE from client 1 to server')
-    )
-    transporterServerTo2.on(String(MessageType.DOCUMENT_UPDATE), () =>
-      console.debug('Received DOCUMENT_UPDATE from client 2 to server')
-    )
-    transporterClient1.on(String(MessageType.DOCUMENT_UPDATE), () =>
-      console.debug('Received DOCUMENT_UPDATE from server to client 1')
-    )
-    transporterClient2.on(String(MessageType.DOCUMENT_UPDATE), () =>
-      console.debug('Received DOCUMENT_UPDATE from server to client 2')
-    )
-
-    transporterServerTo1.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 1 to server'
-        )
-    )
-    transporterServerTo2.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 2 to server'
-        )
-    )
-    transporterClient1.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 1'
-        )
-    )
-    transporterClient2.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 2'
-        )
-    )
-
-    transporterServerTo1.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 1 to server'
-        )
-    )
-    transporterServerTo2.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 2 to server'
-        )
-    )
-    transporterClient1.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 1'
-        )
-    )
-    transporterClient2.on(
-      String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
-      () =>
-        console.debug(
-          'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 2'
-        )
-    )
-    transporterClient1.on('ready', () => console.debug('Client 1 is ready'))
-    transporterClient2.on('ready', () => console.debug('Client 2 is ready'))
-
-    docServer.on('update', (update: Uint8Array, origin: unknown) => {
-      const message = encodeDocumentUpdateMessage(update)
-      if (origin !== transporterServerTo1) {
-        console.debug('YDoc on Server updated. Sending to Client 1')
-        transporterServerTo1.send(message)
-      }
-      if (origin !== transporterServerTo2) {
-        console.debug('YDoc on Server updated. Sending to Client 2')
-        transporterServerTo2.send(message)
-      }
-    })
-    docClient1.on('update', (update: Uint8Array, origin: unknown) => {
-      if (origin !== transporterClient1) {
-        console.debug('YDoc on client 1 updated. Sending to Server')
-        transporterClient1.send(encodeDocumentUpdateMessage(update))
-      }
-    })
-    docClient2.on('update', (update: Uint8Array, origin: unknown) => {
-      if (origin !== transporterClient2) {
-        console.debug('YDoc on client 2 updated. Sending to Server')
-        transporterClient2.send(encodeDocumentUpdateMessage(update))
-      }
-    })
-
-    transporterClient1.connect(transporterServerTo1)
-    transporterClient2.connect(transporterServerTo2)
-
-    textClient1.insert(0, 'test2')
-    textClient1.insert(0, 'test3')
-    textClient2.insert(0, 'test4')
-
-    expect(textServer.toString()).toBe('test4test3test2This is a test note')
-    expect(textClient1.toString()).toBe('test4test3test2This is a test note')
-    expect(textClient2.toString()).toBe('test4test3test2This is a test note')
-
-    dummyAwareness.destroy()
-    docServer.destroy()
-    docClient1.destroy()
-    docClient2.destroy()
-  }))
diff --git a/commons/src/y-doc-message-transporter.ts b/commons/src/y-doc-message-transporter.ts
deleted file mode 100644
index 3c64e0fac..000000000
--- a/commons/src/y-doc-message-transporter.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import {
-  applyAwarenessUpdateMessage,
-  encodeAwarenessUpdateMessage
-} from './messages/awareness-update-message.js'
-import { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
-import { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
-import { applyDocumentUpdateMessage } from './messages/document-update-message.js'
-import { MessageType } from './messages/message-type.enum.js'
-import { encodeReadyAnswerMessage } from './messages/ready-answer-message.js'
-import { encodeReadyRequestMessage } from './messages/ready-request-message.js'
-import { EventEmitter2 } from 'eventemitter2'
-import { Decoder, readVarUint } from 'lib0/decoding'
-import { Awareness } from 'y-protocols/awareness'
-import { Doc } from 'yjs'
-
-export type Handler = (decoder: Decoder) => void
-
-export type MessageTransporterEvents = {
-  disconnected: () => void
-  connected: () => void
-  ready: () => void
-  synced: () => void
-} & Partial<Record<MessageType, Handler>>
-
-export abstract class YDocMessageTransporter extends EventEmitter2 {
-  private synced = false
-
-  protected constructor(
-    protected readonly doc: Doc,
-    protected readonly awareness: Awareness
-  ) {
-    super()
-    this.on(String(MessageType.READY_REQUEST), () => {
-      this.send(encodeReadyAnswerMessage())
-    })
-    this.on(String(MessageType.READY_ANSWER), () => {
-      this.emit('ready')
-    })
-    this.bindDocumentSyncMessageEvents(doc)
-  }
-
-  public isSynced(): boolean {
-    return this.synced
-  }
-
-  protected onOpen(): void {
-    this.emit('connected')
-    this.send(encodeReadyRequestMessage())
-  }
-
-  protected onClose(): void {
-    this.emit('disconnected')
-  }
-
-  protected markAsSynced(): void {
-    if (!this.synced) {
-      this.synced = true
-      this.emit('synced')
-    }
-  }
-
-  protected decodeMessage(buffer: ArrayBuffer): void {
-    const data = new Uint8Array(buffer)
-    const decoder = new Decoder(data)
-    const messageType = readVarUint(decoder) as MessageType
-
-    switch (messageType) {
-      case MessageType.COMPLETE_DOCUMENT_STATE_REQUEST:
-        this.send(encodeCompleteDocumentStateAnswerMessage(this.doc, decoder))
-        break
-      case MessageType.DOCUMENT_UPDATE:
-        applyDocumentUpdateMessage(decoder, this.doc, this)
-        break
-      case MessageType.COMPLETE_DOCUMENT_STATE_ANSWER:
-        applyDocumentUpdateMessage(decoder, this.doc, this)
-        this.markAsSynced()
-        break
-      case MessageType.COMPLETE_AWARENESS_STATE_REQUEST:
-        this.send(
-          encodeAwarenessUpdateMessage(
-            this.awareness,
-            Array.from(this.awareness.getStates().keys())
-          )
-        )
-        break
-      case MessageType.AWARENESS_UPDATE:
-        applyAwarenessUpdateMessage(decoder, this.awareness, this)
-    }
-
-    this.emit(String(messageType), decoder)
-  }
-
-  private bindDocumentSyncMessageEvents(doc: Doc) {
-    this.on('ready', () => {
-      this.send(encodeCompleteDocumentStateRequestMessage(doc))
-    })
-    this.on('disconnected', () => (this.synced = false))
-  }
-
-  /**
-   * Sends binary data to the client. Closes the connection on errors.
-   *
-   * @param content The binary data to send.
-   */
-  public abstract send(content: Uint8Array): void
-
-  public abstract disconnect(): void
-}
diff --git a/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts b/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts
new file mode 100644
index 000000000..6c7e158f0
--- /dev/null
+++ b/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import {
+  ConnectionState,
+  MessageTransporter
+} from '../message-transporters/message-transporter.js'
+import { Message, MessageType } from '../message-transporters/message.js'
+
+/**
+ * Message transporter for testing purposes that redirects message to another in memory connection message transporter instance.
+ */
+export class InMemoryConnectionMessageTransporter extends MessageTransporter {
+  private otherSide: InMemoryConnectionMessageTransporter | undefined
+
+  constructor(private name: string) {
+    super()
+  }
+
+  public connect(other: InMemoryConnectionMessageTransporter): void {
+    this.otherSide = other
+    other.otherSide = this
+    this.onConnected()
+    other.onConnected()
+  }
+
+  public disconnect(): void {
+    this.onDisconnecting()
+
+    if (this.otherSide) {
+      this.otherSide.onDisconnecting()
+      this.otherSide.otherSide = undefined
+      this.otherSide = undefined
+    }
+  }
+
+  sendMessage(content: Message<MessageType>): void {
+    if (this.otherSide === undefined) {
+      throw new Error('Disconnected')
+    }
+    console.debug(`${this.name}`, 'Sending', content)
+    this.otherSide?.receiveMessage(content)
+  }
+
+  getConnectionState(): ConnectionState {
+    return this.otherSide !== undefined
+      ? ConnectionState.CONNECTED
+      : ConnectionState.DISCONNECT
+  }
+}
diff --git a/commons/src/y-doc-sync/realtime-doc.test.ts b/commons/src/y-doc-sync/realtime-doc.test.ts
new file mode 100644
index 000000000..f0eee8180
--- /dev/null
+++ b/commons/src/y-doc-sync/realtime-doc.test.ts
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { RealtimeDoc } from './realtime-doc.js'
+import { describe, expect, it } from '@jest/globals'
+
+describe('websocket-doc', () => {
+  it('saves the initial content', () => {
+    const textContent = 'textContent'
+    const websocketDoc = new RealtimeDoc(textContent)
+
+    expect(websocketDoc.getCurrentContent()).toBe(textContent)
+  })
+})
diff --git a/commons/src/y-doc-sync/realtime-doc.ts b/commons/src/y-doc-sync/realtime-doc.ts
new file mode 100644
index 000000000..13bd7bdf8
--- /dev/null
+++ b/commons/src/y-doc-sync/realtime-doc.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Doc } from 'yjs'
+import { Text as YText } from 'yjs'
+
+const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'
+
+/**
+ * This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
+ */
+export class RealtimeDoc extends Doc {
+  /**
+   * Creates a new instance.
+   *
+   * The new instance is filled with the given initial content.
+   *
+   * @param initialContent - the initial content of the {@link Doc YDoc}
+   */
+  constructor(initialContent?: string) {
+    super()
+    if (initialContent) {
+      this.getMarkdownContentChannel().insert(0, initialContent)
+    }
+  }
+
+  /**
+   * Extracts the {@link YText text channel} that contains the markdown code.
+   *
+   * @return The markdown channel
+   */
+  public getMarkdownContentChannel(): YText {
+    return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
+  }
+
+  /**
+   * Gets the current content of the note as it's currently edited in realtime.
+   *
+   * Please be aware that the return of this method may be very quickly outdated.
+   *
+   * @return The current note content.
+   */
+  public getCurrentContent(): string {
+    return this.getMarkdownContentChannel().toString()
+  }
+}
diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.test.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.test.ts
new file mode 100644
index 000000000..ce14ea802
--- /dev/null
+++ b/commons/src/y-doc-sync/y-doc-sync-adapter.test.ts
@@ -0,0 +1,162 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Message, MessageType } from '../message-transporters/message.js'
+import { InMemoryConnectionMessageTransporter } from './in-memory-connection-message.transporter.js'
+import { RealtimeDoc } from './realtime-doc.js'
+import { YDocSyncClientAdapter } from './y-doc-sync-client-adapter.js'
+import { YDocSyncServerAdapter } from './y-doc-sync-server-adapter.js'
+import { describe, expect, it } from '@jest/globals'
+
+describe('message transporter', () => {
+  it('server client communication', async () => {
+    const docServer: RealtimeDoc = new RealtimeDoc('This is a test note')
+    const docClient1: RealtimeDoc = new RealtimeDoc()
+    const docClient2: RealtimeDoc = new RealtimeDoc()
+
+    const textServer = docServer.getMarkdownContentChannel()
+    const textClient1 = docClient1.getMarkdownContentChannel()
+    const textClient2 = docClient2.getMarkdownContentChannel()
+
+    textServer.observe(() =>
+      console.debug('textServer', new Date(), textServer.toString())
+    )
+    textClient1.observe(() =>
+      console.debug('textClient1', new Date(), textClient1.toString())
+    )
+    textClient2.observe(() =>
+      console.debug('textClient2', new Date(), textClient2.toString())
+    )
+
+    const transporterServerTo1 = new InMemoryConnectionMessageTransporter('s>1')
+    const transporterServerTo2 = new InMemoryConnectionMessageTransporter('s>2')
+    const transporterClient1 = new InMemoryConnectionMessageTransporter('1>s')
+    const transporterClient2 = new InMemoryConnectionMessageTransporter('2>s')
+
+    transporterServerTo1.on(MessageType.NOTE_CONTENT_UPDATE, () =>
+      console.debug('Received NOTE_CONTENT_UPDATE from client 1 to server')
+    )
+    transporterServerTo2.on(MessageType.NOTE_CONTENT_UPDATE, () =>
+      console.debug('Received NOTE_CONTENT_UPDATE from client 2 to server')
+    )
+    transporterClient1.on(MessageType.NOTE_CONTENT_UPDATE, () =>
+      console.debug('Received NOTE_CONTENT_UPDATE from server to client 1')
+    )
+    transporterClient2.on(MessageType.NOTE_CONTENT_UPDATE, () =>
+      console.debug('Received NOTE_CONTENT_UPDATE from server to client 2')
+    )
+    transporterServerTo1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
+      console.debug('Received NOTE_CONTENT_REQUEST from client 1 to server')
+    )
+    transporterServerTo2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
+      console.debug('Received NOTE_CONTENT_REQUEST from client 2 to server')
+    )
+    transporterClient1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
+      console.debug('Received NOTE_CONTENT_REQUEST from server to client 1')
+    )
+    transporterClient2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
+      console.debug('Received NOTE_CONTENT_REQUEST from server to client 2')
+    )
+    transporterClient1.on('connected', () => console.debug('1>s is connected'))
+    transporterClient2.on('connected', () => console.debug('2>s is connected'))
+    transporterServerTo1.on('connected', () =>
+      console.debug('s>1 is connected')
+    )
+    transporterServerTo2.on('connected', () =>
+      console.debug('s>2 is connected')
+    )
+
+    docServer.on('update', (update: Uint8Array, origin: unknown) => {
+      const message: Message<MessageType.NOTE_CONTENT_UPDATE> = {
+        type: MessageType.NOTE_CONTENT_UPDATE,
+        payload: Array.from(update)
+      }
+      if (origin !== transporterServerTo1) {
+        console.debug('YDoc on Server updated. Sending to Client 1')
+        transporterServerTo1.sendMessage(message)
+      }
+      if (origin !== transporterServerTo2) {
+        console.debug('YDoc on Server updated. Sending to Client 2')
+        transporterServerTo2.sendMessage(message)
+      }
+    })
+    docClient1.on('update', (update: Uint8Array, origin: unknown) => {
+      if (origin !== transporterClient1) {
+        console.debug('YDoc on client 1 updated. Sending to Server')
+      }
+    })
+    docClient2.on('update', (update: Uint8Array, origin: unknown) => {
+      if (origin !== transporterClient2) {
+        console.debug('YDoc on client 2 updated. Sending to Server')
+      }
+    })
+
+    const yDocSyncAdapter1 = new YDocSyncClientAdapter(transporterClient1)
+    yDocSyncAdapter1.setYDoc(docClient1)
+
+    const yDocSyncAdapter2 = new YDocSyncClientAdapter(transporterClient2)
+    yDocSyncAdapter2.setYDoc(docClient2)
+
+    const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter(
+      transporterServerTo1
+    )
+    yDocSyncAdapterServerTo1.setYDoc(docServer)
+
+    const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter(
+      transporterServerTo2
+    )
+    yDocSyncAdapterServerTo2.setYDoc(docServer)
+
+    const waitForClient1Sync = new Promise<void>((resolve) => {
+      yDocSyncAdapter1.doAsSoonAsSynced(() => {
+        console.debug('client 1 received the first sync')
+        resolve()
+      })
+    })
+    const waitForClient2Sync = new Promise<void>((resolve) => {
+      yDocSyncAdapter2.doAsSoonAsSynced(() => {
+        console.debug('client 2 received the first sync')
+        resolve()
+      })
+    })
+    const waitForServerTo11Sync = new Promise<void>((resolve) => {
+      yDocSyncAdapterServerTo1.doAsSoonAsSynced(() => {
+        console.debug('server 1 received the first sync')
+        resolve()
+      })
+    })
+    const waitForServerTo21Sync = new Promise<void>((resolve) => {
+      yDocSyncAdapterServerTo2.doAsSoonAsSynced(() => {
+        console.debug('server 2 received the first sync')
+        resolve()
+      })
+    })
+
+    transporterClient1.connect(transporterServerTo1)
+    transporterClient2.connect(transporterServerTo2)
+
+    yDocSyncAdapter1.requestDocumentState()
+    yDocSyncAdapter2.requestDocumentState()
+
+    await Promise.all([
+      waitForClient1Sync,
+      waitForClient2Sync,
+      waitForServerTo11Sync,
+      waitForServerTo21Sync
+    ])
+
+    textClient1.insert(0, 'test2')
+    textClient1.insert(0, 'test3')
+    textClient2.insert(0, 'test4')
+
+    expect(textServer.toString()).toBe('test4test3test2This is a test note')
+    expect(textClient1.toString()).toBe('test4test3test2This is a test note')
+    expect(textClient2.toString()).toBe('test4test3test2This is a test note')
+
+    docServer.destroy()
+    docClient1.destroy()
+    docClient2.destroy()
+  })
+})
diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.ts
new file mode 100644
index 000000000..65b32a347
--- /dev/null
+++ b/commons/src/y-doc-sync/y-doc-sync-adapter.ts
@@ -0,0 +1,148 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { MessageTransporter } from '../message-transporters/message-transporter.js'
+import { Message, MessageType } from '../message-transporters/message.js'
+import { Listener } from 'eventemitter2'
+import { EventEmitter2 } from 'eventemitter2'
+import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
+
+type EventMap = Record<'synced' | 'desynced', () => void>
+
+/**
+ * Sends and processes messages that are used to first-synchronize and update a {@link Doc y-doc}.
+ */
+export abstract class YDocSyncAdapter {
+  public readonly eventEmitter = new EventEmitter2<EventMap>()
+
+  protected doc: Doc | undefined
+
+  private destroyYDocUpdateCallback: undefined | (() => void)
+  private destroyEventListenerCallback: undefined | (() => void)
+  private synced = false
+
+  constructor(protected readonly messageTransporter: MessageTransporter) {
+    this.bindDocumentSyncMessageEvents()
+  }
+
+  /**
+   * Executes the given callback as soon as the sync adapter has synchronized the y-doc.
+   * If the y-doc has already been synchronized then the callback is executed immediately.
+   *
+   * @param callback the callback to execute
+   * @return The event listener that waits for the sync event
+   */
+  public doAsSoonAsSynced(callback: () => void): Listener {
+    if (this.isSynced()) {
+      callback()
+    }
+    return this.eventEmitter.on('synced', callback, {
+      objectify: true
+    }) as Listener
+  }
+
+  public getMessageTransporter(): MessageTransporter {
+    return this.messageTransporter
+  }
+
+  public isSynced(): boolean {
+    return this.synced
+  }
+
+  /**
+   * Sets the {@link Doc y-doc} that should be synchronized.
+   *
+   * @param doc the doc to synchronize.
+   */
+  public setYDoc(doc: Doc | undefined): void {
+    this.doc = doc
+
+    this.destroyYDocUpdateCallback?.()
+    if (!doc) {
+      return
+    }
+    const yDocUpdateCallback = this.processDocUpdate.bind(this)
+    doc.on('update', yDocUpdateCallback)
+    this.destroyYDocUpdateCallback = () => doc.off('update', yDocUpdateCallback)
+    this.eventEmitter.emit('desynced')
+  }
+
+  public destroy(): void {
+    this.destroyYDocUpdateCallback?.()
+    this.destroyEventListenerCallback?.()
+  }
+
+  protected bindDocumentSyncMessageEvents(): void {
+    const stateRequestListener = this.messageTransporter.on(
+      MessageType.NOTE_CONTENT_STATE_REQUEST,
+      (payload) => {
+        if (this.doc) {
+          this.messageTransporter.sendMessage({
+            type: MessageType.NOTE_CONTENT_UPDATE,
+            payload: Array.from(
+              encodeStateAsUpdate(this.doc, new Uint8Array(payload.payload))
+            )
+          })
+        }
+      },
+      { objectify: true }
+    ) as Listener
+
+    const disconnectedListener = this.messageTransporter.on(
+      'disconnected',
+      () => {
+        this.synced = false
+        this.eventEmitter.emit('desynced')
+        this.destroy()
+      },
+      { objectify: true }
+    ) as Listener
+
+    const noteContentUpdateListener = this.messageTransporter.on(
+      MessageType.NOTE_CONTENT_UPDATE,
+      (payload) => {
+        if (this.doc) {
+          applyUpdate(this.doc, new Uint8Array(payload.payload), this)
+        }
+      },
+      { objectify: true }
+    ) as Listener
+
+    this.destroyEventListenerCallback = () => {
+      stateRequestListener.off()
+      disconnectedListener.off()
+      noteContentUpdateListener.off()
+    }
+  }
+
+  private processDocUpdate(update: Uint8Array, origin: unknown): void {
+    if (!this.isSynced() || origin === this) {
+      return
+    }
+    const message: Message<MessageType.NOTE_CONTENT_UPDATE> = {
+      type: MessageType.NOTE_CONTENT_UPDATE,
+      payload: Array.from(update)
+    }
+
+    this.messageTransporter.sendMessage(message)
+  }
+
+  protected markAsSynced(): void {
+    if (this.synced) {
+      return
+    }
+    this.synced = true
+    this.eventEmitter.emit('synced')
+  }
+
+  public requestDocumentState(): void {
+    if (this.doc) {
+      this.messageTransporter.sendMessage({
+        type: MessageType.NOTE_CONTENT_STATE_REQUEST,
+        payload: Array.from(encodeStateVector(this.doc))
+      })
+    }
+  }
+}
diff --git a/commons/src/y-doc-sync/y-doc-sync-client-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-client-adapter.ts
new file mode 100644
index 000000000..0332d9a3b
--- /dev/null
+++ b/commons/src/y-doc-sync/y-doc-sync-client-adapter.ts
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { MessageType } from '../message-transporters/message.js'
+import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
+
+export class YDocSyncClientAdapter extends YDocSyncAdapter {
+  protected bindDocumentSyncMessageEvents() {
+    super.bindDocumentSyncMessageEvents()
+
+    this.messageTransporter.on(MessageType.NOTE_CONTENT_UPDATE, () => {
+      this.markAsSynced()
+    })
+  }
+}
diff --git a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
new file mode 100644
index 000000000..54df1395e
--- /dev/null
+++ b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { MessageTransporter } from '../message-transporters/message-transporter.js'
+import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
+
+export class YDocSyncServerAdapter extends YDocSyncAdapter {
+  constructor(readonly messageTransporter: MessageTransporter) {
+    super(messageTransporter)
+    this.markAsSynced()
+  }
+}
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index a3fbd8a73..29813983b 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -583,6 +583,9 @@
       "text": "You were redirected to the history page, because the note you just edited was deleted."
     }
   },
+  "realtime": {
+    "reconnect": "Reconnecting to HedgeDoc…"
+  },
   "settings": {
     "title": "Settings",
     "editor": {
diff --git a/frontend/package.json b/frontend/package.json
index a3a03cc8c..b3c67b992 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -121,8 +121,6 @@
     "vega-lite": "5.6.1",
     "words-count": "2.0.2",
     "ws": "8.13.0",
-    "y-codemirror.next": "0.3.2",
-    "y-protocols": "1.0.5",
     "yjs": "13.5.51"
   },
   "devDependencies": {
diff --git a/frontend/src/components/editor-page/editor-page-content.tsx b/frontend/src/components/editor-page/editor-page-content.tsx
index 38e99f4ab..42ae90eed 100644
--- a/frontend/src/components/editor-page/editor-page-content.tsx
+++ b/frontend/src/components/editor-page/editor-page-content.tsx
@@ -20,6 +20,7 @@ import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-ent
 import { Sidebar } from './sidebar/sidebar'
 import { Splitter } from './splitter/splitter'
 import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
+import { RealtimeConnectionModal } from './websocket-connection-modal/realtime-connection-modal'
 import equal from 'fast-deep-equal'
 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
@@ -79,7 +80,6 @@ export const EditorPageContent: React.FC = () => {
   )
 
   useApplyDarkMode()
-
   useUpdateLocalHistoryEntry()
 
   const setRendererToScrollSource = useCallback(() => {
@@ -129,6 +129,7 @@ export const EditorPageContent: React.FC = () => {
         <CommunicatorImageLightbox />
         <HeadMetaProperties />
         <MotdModal />
+        <RealtimeConnectionModal />
         <div className={'d-flex flex-column vh-100'}>
           <AppBar mode={AppBarMode.EDITOR} />
           <div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts
new file mode 100644
index 000000000..d84f96e09
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import type { ChangeSpec, Transaction } from '@codemirror/state'
+import { Annotation } from '@codemirror/state'
+import type { EditorView, PluginValue } from '@codemirror/view'
+import type { ViewUpdate } from '@codemirror/view'
+import type { Text as YText } from 'yjs'
+import type { Transaction as YTransaction, YTextEvent } from 'yjs'
+
+const syncAnnotation = Annotation.define()
+
+/**
+ * Synchronizes the content of a codemirror with a {@link YText y.js text channel}.
+ */
+export class YTextSyncViewPlugin implements PluginValue {
+  private readonly observer: YTextSyncViewPlugin['onYTextUpdate']
+  private firstUpdate = true
+
+  constructor(private view: EditorView, private readonly yText: YText, pluginLoaded: () => void) {
+    this.observer = this.onYTextUpdate.bind(this)
+    this.yText.observe(this.observer)
+    pluginLoaded()
+  }
+
+  private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void {
+    if (transaction.origin === this) {
+      return
+    }
+    this.view.dispatch({ changes: this.calculateChanges(event), annotations: [syncAnnotation.of(this)] })
+  }
+
+  private calculateChanges(event: YTextEvent): ChangeSpec[] {
+    const [changes] = event.delta.reduce(
+      ([changes, position], delta) => {
+        if (delta.insert !== undefined && typeof delta.insert === 'string') {
+          return [[...changes, { from: position, to: position, insert: delta.insert }], position]
+        } else if (delta.delete !== undefined) {
+          return [[...changes, { from: position, to: position + delta.delete, insert: '' }], position + delta.delete]
+        } else if (delta.retain !== undefined) {
+          return [changes, position + delta.retain]
+        } else {
+          return [changes, position]
+        }
+      },
+      [[], 0] as [ChangeSpec[], number]
+    )
+    return this.addDeleteAllChanges(changes)
+  }
+
+  private addDeleteAllChanges(changes: ChangeSpec[]): ChangeSpec[] {
+    if (this.firstUpdate) {
+      this.firstUpdate = false
+      return [{ from: 0, to: this.view.state.doc.length, insert: '' }, ...changes]
+    } else {
+      return changes
+    }
+  }
+
+  public update(update: ViewUpdate): void {
+    if (!update.docChanged) {
+      return
+    }
+    update.transactions
+      .filter((transaction) => transaction.annotation(syncAnnotation) !== this)
+      .forEach((transaction) => this.applyTransaction(transaction))
+  }
+
+  private applyTransaction(transaction: Transaction): void {
+    this.yText.doc?.transact(() => {
+      let positionAdjustment = 0
+      transaction.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
+        const insertText = insert.sliceString(0, insert.length, '\n')
+        if (fromA !== toA) {
+          this.yText.delete(fromA + positionAdjustment, toA - fromA)
+        }
+        if (insertText.length > 0) {
+          this.yText.insert(fromA + positionAdjustment, insertText)
+        }
+        positionAdjustment += insertText.length - (toA - fromA)
+      })
+    }, this)
+  }
+
+  public destroy(): void {
+    this.yText.unobserve(this.observer)
+  }
+}
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts
new file mode 100644
index 000000000..3b8e69c76
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import styles from './cursor-colors.module.scss'
+
+export const createCursorCssClass = (styleIndex: number): string => {
+  return styles[`cursor-${Math.max(Math.min(styleIndex, 7), 0)}`]
+}
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss
new file mode 100644
index 000000000..517e9c1b5
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss
@@ -0,0 +1,37 @@
+/*!
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+.cursor-0 {
+  --color: #780c0c;
+}
+
+.cursor-1 {
+  --color: #ff1111;
+}
+
+.cursor-2 {
+  --color: #1149ff;
+}
+
+.cursor-3 {
+  --color: #11ff39;
+}
+
+.cursor-4 {
+  --color: #cb11ff;
+}
+
+.cursor-5 {
+  --color: #ffff00;
+}
+
+.cursor-6 {
+  --color: #00fff2;
+}
+
+.cursor-7 {
+  --color: #ff8000;
+}
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts
new file mode 100644
index 000000000..ad1b3de1c
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { createCursorCssClass } from './create-cursor-css-class'
+import { RemoteCursorMarker } from './remote-cursor-marker'
+import styles from './style.module.scss'
+import type { Extension, Transaction } from '@codemirror/state'
+import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
+import type { ViewUpdate } from '@codemirror/view'
+import { layer, RectangleMarker } from '@codemirror/view'
+import { Optional } from '@mrdrogdrog/optional'
+import equal from 'fast-deep-equal'
+
+export interface RemoteCursor {
+  displayName: string
+  from: number
+  to?: number
+  styleIndex: number
+}
+
+/**
+ * Used to provide a new set of {@link RemoteCursor remote cursors} to a codemirror state.
+ */
+export const remoteCursorUpdateEffect = StateEffect.define<RemoteCursor[]>()
+
+/**
+ * Saves the currently visible {@link RemoteCursor remote cursors}
+ * and saves new cursors if a transaction with an {@link remoteCursorUpdateEffect update effect} has been dispatched.
+ */
+export const remoteCursorStateField = StateField.define<RemoteCursor[]>({
+  compare(a: RemoteCursor[], b: RemoteCursor[]): boolean {
+    return equal(a, b)
+  },
+  create(): RemoteCursor[] {
+    return []
+  },
+  update(currentValue: RemoteCursor[], transaction: Transaction): RemoteCursor[] {
+    return Optional.ofNullable(transaction.effects.find((effect) => effect.is(remoteCursorUpdateEffect)))
+      .map((remoteCursor) => remoteCursor.value as RemoteCursor[])
+      .orElse(currentValue)
+  }
+})
+
+/**
+ * Checks if the given {@link ViewUpdate view update} should trigger a rerender of remote cursor components.
+ * @param update The update to check
+ */
+const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
+  const effect = update.transactions
+    .flatMap((transaction) => transaction.effects)
+    .filter((effect) => effect.is(remoteCursorUpdateEffect))
+
+  return update.docChanged || update.viewportChanged || effect.length > 0
+}
+
+/**
+ * Creates the codemirror extension that renders the remote cursor selection layer.
+ * @return The created codemirror extension
+ */
+export const createCursorLayer = (): Extension =>
+  layer({
+    above: true,
+    class: styles.cursorLayer,
+    update: isRemoteCursorUpdate,
+    markers: (view) => {
+      return view.state.field(remoteCursorStateField).flatMap((remoteCursor) => {
+        const selectionRange = EditorSelection.cursor(remoteCursor.from)
+        return RemoteCursorMarker.createCursor(view, selectionRange, remoteCursor.displayName, remoteCursor.styleIndex)
+      })
+    }
+  })
+
+/**
+ * Creates the codemirror extension that renders the blinking remote cursor layer.
+ * @return The created codemirror extension
+ */
+export const createSelectionLayer = (): Extension =>
+  layer({
+    above: false,
+    class: styles.selectionLayer,
+    update: isRemoteCursorUpdate,
+    markers: (view) => {
+      return view.state
+        .field(remoteCursorStateField)
+        .filter((remoteCursor) => remoteCursor.to !== undefined && remoteCursor.from !== remoteCursor.to)
+        .flatMap((remoteCursor) => {
+          const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number)
+          return RectangleMarker.forRange(
+            view,
+            `${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`,
+            selectionRange
+          )
+        })
+    }
+  })
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts
new file mode 100644
index 000000000..5c61e1ecf
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import type { RemoteCursor } from './cursor-layers-extensions'
+import { remoteCursorUpdateEffect } from './cursor-layers-extensions'
+import type { EditorView, PluginValue } from '@codemirror/view'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { MessageType } from '@hedgedoc/commons'
+import type { Listener } from 'eventemitter2'
+
+/**
+ * Listens for remote cursor state messages from the backend and dispatches them into the codemirror.
+ */
+export class ReceiveRemoteCursorViewPlugin implements PluginValue {
+  private readonly listener: Listener
+
+  constructor(view: EditorView, messageTransporter: MessageTransporter) {
+    this.listener = messageTransporter.on(
+      MessageType.REALTIME_USER_STATE_SET,
+      ({ payload }) => {
+        const cursors: RemoteCursor[] = payload.map((user) => ({
+          from: user.cursor.from,
+          to: user.cursor.to,
+          displayName: user.displayName,
+          styleIndex: user.styleIndex
+        }))
+        view.dispatch({
+          effects: [remoteCursorUpdateEffect.of(cursors)]
+        })
+      },
+      { objectify: true }
+    ) as Listener
+  }
+
+  destroy() {
+    this.listener.off()
+  }
+}
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts
new file mode 100644
index 000000000..bd7c9143b
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { createCursorCssClass } from './create-cursor-css-class'
+import styles from './style.module.scss'
+import type { SelectionRange } from '@codemirror/state'
+import type { LayerMarker, EditorView, Rect } from '@codemirror/view'
+import { Direction } from '@codemirror/view'
+
+/**
+ * Renders a blinking cursor to indicate the cursor of another user.
+ */
+export class RemoteCursorMarker implements LayerMarker {
+  constructor(
+    private left: number,
+    private top: number,
+    private height: number,
+    private name: string,
+    private styleIndex: number
+  ) {}
+
+  draw(): HTMLElement {
+    const elt = document.createElement('div')
+    this.adjust(elt)
+    return elt
+  }
+
+  update(elt: HTMLElement): boolean {
+    this.adjust(elt)
+    return true
+  }
+
+  adjust(element: HTMLElement) {
+    element.style.left = `${this.left}px`
+    element.style.top = `${this.top}px`
+    element.style.height = `${this.height}px`
+    element.style.setProperty('--name', `"${this.name}"`)
+    element.className = `${styles.cursor} ${createCursorCssClass(this.styleIndex)}`
+  }
+
+  eq(other: RemoteCursorMarker): boolean {
+    return (
+      this.left === other.left && this.top === other.top && this.height === other.height && this.name === other.name
+    )
+  }
+
+  public static createCursor(
+    view: EditorView,
+    position: SelectionRange,
+    displayName: string,
+    styleIndex: number
+  ): RemoteCursorMarker[] {
+    const absolutePosition = this.calculateAbsoluteCursorPosition(position, view)
+    if (!absolutePosition || styleIndex < 0) {
+      return []
+    }
+    const rect = view.scrollDOM.getBoundingClientRect()
+    const left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth
+    const baseLeft = left - view.scrollDOM.scrollLeft
+    const baseTop = rect.top - view.scrollDOM.scrollTop
+    return [
+      new RemoteCursorMarker(
+        absolutePosition.left - baseLeft,
+        absolutePosition.top - baseTop,
+        absolutePosition.bottom - absolutePosition.top,
+        displayName,
+        styleIndex
+      )
+    ]
+  }
+
+  private static calculateAbsoluteCursorPosition(position: SelectionRange, view: EditorView): Rect | null {
+    const cappedPositionHead = Math.max(0, Math.min(view.state.doc.length, position.head))
+    return view.coordsAtPos(cappedPositionHead, position.assoc || 1)
+  }
+}
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts
new file mode 100644
index 000000000..571018cf1
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import type { SelectionRange } from '@codemirror/state'
+import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { MessageType } from '@hedgedoc/commons'
+import type { Listener } from 'eventemitter2'
+
+/**
+ * Sends the main cursor of a codemirror to the backend using a given {@link MessageTransporter}.
+ */
+export class SendCursorViewPlugin implements PluginValue {
+  private lastCursor: SelectionRange | undefined
+  private listener: Listener
+
+  constructor(private view: EditorView, private messageTransporter: MessageTransporter) {
+    this.listener = messageTransporter.doAsSoonAsReady(() => {
+      this.sendCursor(this.lastCursor)
+    })
+  }
+
+  destroy() {
+    this.listener.off()
+  }
+
+  update(update: ViewUpdate) {
+    if (!update.selectionSet && !update.focusChanged && !update.docChanged) {
+      return
+    }
+    this.sendCursor(update.state.selection.main)
+  }
+
+  private sendCursor(currentCursor: SelectionRange | undefined) {
+    if (
+      !this.messageTransporter.isReady() ||
+      currentCursor === undefined ||
+      (this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from)
+    ) {
+      return
+    }
+    this.lastCursor = currentCursor
+    this.messageTransporter.sendMessage({
+      type: MessageType.REALTIME_USER_SINGLE_UPDATE,
+      payload: {
+        from: currentCursor.from ?? 0,
+        to: currentCursor?.to
+      }
+    })
+  }
+}
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss
new file mode 100644
index 000000000..3c616576b
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss
@@ -0,0 +1,40 @@
+/*!
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+.cursorLayer {
+  --color: #868686;
+  .cursor {
+    border-left: 2px solid var(--color);
+    box-sizing: content-box;
+    &:hover {
+      &:before {
+        opacity: 1
+      }
+    }
+    &:before {
+      content: var(--name);
+      font-size: 0.8em;
+      background: var(--color);
+      position: absolute;
+      top: -1.2em;
+      right: 2px;
+      color: white;
+      padding: 2px 5px;
+      height: 20px;
+      opacity: 0;
+      transition: opacity 0.1s;
+      white-space: nowrap;
+    }
+  }
+}
+
+.selectionLayer {
+  --color: #868686;
+  .cursor {
+    background-color: var(--color);
+    opacity: 0.5;
+  }
+}
diff --git a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx
index 48460dba5..9dfdd7172 100644
--- a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx
+++ b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx
@@ -4,31 +4,30 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { useApplicationState } from '../../../hooks/common/use-application-state'
-import { useBaseUrl, ORIGIN } from '../../../hooks/common/use-base-url'
+import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
 import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
 import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
 import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
 import type { ScrollProps } from '../synced-scroll/scroll-props'
 import styles from './extended-codemirror/codemirror.module.scss'
-import { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension'
-import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension'
-import { useCodeMirrorSpellCheckExtension } from './hooks/code-mirror-extensions/use-code-mirror-spell-check-extension'
+import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-extension'
+import { useCodeMirrorRemoteCursorsExtension } from './hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions'
+import { useCodeMirrorScrollWatchExtension } from './hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension'
+import { useCodeMirrorSpellCheckExtension } from './hooks/codemirror-extensions/use-code-mirror-spell-check-extension'
 import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
 import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
 import { useApplyScrollState } from './hooks/use-apply-scroll-state'
 import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
 import { useUpdateCodeMirrorReference } from './hooks/use-update-code-mirror-reference'
-import { useAwareness } from './hooks/yjs/use-awareness'
 import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux'
 import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension'
-import { useInsertNoteContentIntoYTextInMockModeEffect } from './hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect'
-import { useIsConnectionSynced } from './hooks/yjs/use-is-connection-synced'
 import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text'
-import { useOnFirstEditorUpdateExtension } from './hooks/yjs/use-on-first-editor-update-extension'
 import { useOnMetadataUpdated } from './hooks/yjs/use-on-metadata-updated'
 import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted'
-import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection'
+import { useRealtimeConnection } from './hooks/yjs/use-realtime-connection'
+import { useReceiveRealtimeUsers } from './hooks/yjs/use-receive-realtime-users'
 import { useYDoc } from './hooks/yjs/use-y-doc'
+import { useYDocSyncClientAdapter } from './hooks/yjs/use-y-doc-sync-client-adapter'
 import { useLinter } from './linter/linter'
 import { MaxLengthWarning } from './max-length-warning/max-length-warning'
 import { StatusBar } from './status-bar/status-bar'
@@ -40,9 +39,11 @@ import { lintGutter } from '@codemirror/lint'
 import { oneDark } from '@codemirror/theme-one-dark'
 import { EditorView } from '@codemirror/view'
 import ReactCodeMirror from '@uiw/react-codemirror'
-import React, { useMemo } from 'react'
+import React, { useEffect, useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 
+export type EditorPaneProps = ScrollProps
+
 /**
  * Renders the text editor pane of the editor.
  * The used editor is {@link ReactCodeMirror code mirror}.
@@ -52,41 +53,41 @@ import { useTranslation } from 'react-i18next'
  * @param onMakeScrollSource The callback to request to become the scroll source.
  * @external {ReactCodeMirror} https://npmjs.com/@uiw/react-codemirror
  */
-export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
-  const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
-
+export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
   useApplyScrollState(scrollState)
 
+  const messageTransporter = useRealtimeConnection()
+  const yDoc = useYDoc(messageTransporter)
+  const yText = useMarkdownContentYText(yDoc)
   const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
   const tablePasteExtensions = useCodeMirrorTablePasteExtension()
   const fileInsertExtension = useCodeMirrorFileInsertExtension()
   const spellCheckExtension = useCodeMirrorSpellCheckExtension()
   const cursorActivityExtension = useCursorActivityCallback()
-
   const updateViewContextExtension = useUpdateCodeMirrorReference()
 
-  const yDoc = useYDoc()
-  const awareness = useAwareness(yDoc)
-  const yText = useMarkdownContentYText(yDoc)
-  const websocketConnection = useWebsocketConnection(yDoc, awareness)
-  const connectionSynced = useIsConnectionSynced(websocketConnection)
-  useBindYTextToRedux(yText)
-  useOnMetadataUpdated(websocketConnection)
-  useOnNoteDeleted(websocketConnection)
+  const remoteCursorsExtension = useCodeMirrorRemoteCursorsExtension(messageTransporter)
 
-  const yjsExtension = useCodeMirrorYjsExtension(yText, awareness)
-  const [firstEditorUpdateExtension, firstUpdateHappened] = useOnFirstEditorUpdateExtension()
-  useInsertNoteContentIntoYTextInMockModeEffect(firstUpdateHappened, websocketConnection)
-  const linter = useLinter()
+  const linterExtension = useLinter()
+
+  const syncAdapter = useYDocSyncClientAdapter(messageTransporter, yDoc)
+  const yjsExtension = useCodeMirrorYjsExtension(yText, syncAdapter)
+
+  useOnMetadataUpdated(messageTransporter)
+  useOnNoteDeleted(messageTransporter)
+
+  useBindYTextToRedux(yText)
+  useReceiveRealtimeUsers(messageTransporter)
 
   const extensions = useMemo(
     () => [
-      linter,
+      linterExtension,
       lintGutter(),
       markdown({
         base: markdownLanguage,
         codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
       }),
+      remoteCursorsExtension,
       EditorView.lineWrapping,
       editorScrollExtension,
       tablePasteExtensions,
@@ -95,34 +96,40 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
       cursorActivityExtension,
       updateViewContextExtension,
       yjsExtension,
-      firstEditorUpdateExtension,
       spellCheckExtension
     ],
     [
-      linter,
+      linterExtension,
+      remoteCursorsExtension,
       editorScrollExtension,
       tablePasteExtensions,
       fileInsertExtension,
       cursorActivityExtension,
       updateViewContextExtension,
       yjsExtension,
-      firstEditorUpdateExtension,
       spellCheckExtension
     ]
   )
 
   useOnImageUploadFromRenderer()
 
+  const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
   const codeMirrorClassName = useMemo(
     () => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
     [ligaturesEnabled]
   )
 
   const { t } = useTranslation()
-
   const darkModeActivated = useDarkModeState()
-
   const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
+  const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
+
+  useEffect(() => {
+    const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
+    return () => {
+      listener.off()
+    }
+  }, [messageTransporter])
 
   return (
     <div
@@ -130,11 +137,11 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
       onTouchStart={onMakeScrollSource}
       onMouseEnter={onMakeScrollSource}
       {...cypressId('editor-pane')}
-      {...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}>
+      {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
       <MaxLengthWarning />
       <ToolBar />
       <ReactCodeMirror
-        editable={firstUpdateHappened && connectionSynced}
+        editable={updateViewContextExtension !== null && isSynced}
         placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
         extensions={extensions}
         width={'100%'}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-insert-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-file-insert-extension.ts
similarity index 100%
rename from frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-insert-extension.ts
rename to frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-file-insert-extension.ts
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts
new file mode 100644
index 000000000..1816b5c84
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import {
+  createCursorLayer,
+  createSelectionLayer,
+  remoteCursorStateField
+} from '../../codemirror-extensions/remote-cursors/cursor-layers-extensions'
+import { ReceiveRemoteCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin'
+import { SendCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/send-cursor-view-plugin'
+import type { Extension } from '@codemirror/state'
+import { ViewPlugin } from '@codemirror/view'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { useMemo } from 'react'
+
+/**
+ * Bundles all extensions that are needed for the remote cursor display.
+ * @return The created codemirror extensions
+ */
+export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension =>
+  useMemo(
+    () => [
+      remoteCursorStateField.extension,
+      createCursorLayer(),
+      createSelectionLayer(),
+      ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)),
+      ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter))
+    ],
+    [messageTransporter]
+  )
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension.ts
similarity index 100%
rename from frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts
rename to frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension.ts
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-spell-check-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-spell-check-extension.ts
similarity index 100%
rename from frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-spell-check-extension.ts
rename to frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-spell-check-extension.ts
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts
deleted file mode 100644
index 981268078..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { MARKDOWN_CONTENT_CHANNEL_NAME, YDocMessageTransporter } from '@hedgedoc/commons'
-import type { Awareness } from 'y-protocols/awareness'
-import type { Doc } from 'yjs'
-
-/**
- * A mocked connection that doesn't send or receive any data and is instantly ready.
- */
-export class MockConnection extends YDocMessageTransporter {
-  constructor(doc: Doc, awareness: Awareness) {
-    super(doc, awareness)
-    this.onOpen()
-    this.emit('ready')
-  }
-
-  /**
-   * Simulates a complete sync from the server by inserting the given content at position 0 of the editor yText channel.
-   *
-   * @param content The content to insert
-   */
-  public simulateFirstSync(content: string): void {
-    const yText = this.doc.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
-    yText.insert(0, content)
-    super.markAsSynced()
-  }
-
-  disconnect(): void {
-    //Intentionally left empty because this is a mocked connection
-  }
-
-  send(): void {
-    //Intentionally left empty because this is a mocked connection
-  }
-}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts
deleted file mode 100644
index 12ffb91a8..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { useApplicationState } from '../../../../../hooks/common/use-application-state'
-import { addOnlineUser, removeOnlineUser } from '../../../../../redux/realtime/methods'
-import { ActiveIndicatorStatus } from '../../../../../redux/realtime/types'
-import { Logger } from '../../../../../utils/logger'
-import { useEffect, useMemo } from 'react'
-import { Awareness } from 'y-protocols/awareness'
-import type { Doc } from 'yjs'
-
-const ownAwarenessClientId = -1
-
-interface UserAwarenessState {
-  user: {
-    name: string
-    color: string
-  }
-}
-
-// TODO: [mrdrogdrog] move this code to the server for the initial color setting.
-const userColors = [
-  { color: '#30bced', light: '#30bced33' },
-  { color: '#6eeb83', light: '#6eeb8333' },
-  { color: '#ffbc42', light: '#ffbc4233' },
-  { color: '#ecd444', light: '#ecd44433' },
-  { color: '#ee6352', light: '#ee635233' },
-  { color: '#9ac2c9', light: '#9ac2c933' },
-  { color: '#8acb88', light: '#8acb8833' },
-  { color: '#1be7ff', light: '#1be7ff33' }
-]
-
-const logger = new Logger('useAwareness')
-
-/**
- * Creates an {@link Awareness awareness}, sets the own values (like name, color, etc.) for other clients and writes state changes into the global application state.
- *
- * @param yDoc The {@link Doc yjs document} that handles the communication.
- * @return The created {@link Awareness awareness}
- */
-export const useAwareness = (yDoc: Doc): Awareness => {
-  const ownUsername = useApplicationState((state) => state.user?.username)
-  const awareness = useMemo(() => new Awareness(yDoc), [yDoc])
-
-  useEffect(() => {
-    const userColor = userColors[Math.floor(Math.random() * 8)]
-    if (ownUsername !== undefined) {
-      awareness.setLocalStateField('user', {
-        name: ownUsername,
-        color: userColor.color,
-        colorLight: userColor.light
-      })
-      addOnlineUser(ownAwarenessClientId, {
-        active: ActiveIndicatorStatus.ACTIVE,
-        color: userColor.color,
-        username: ownUsername
-      })
-    }
-
-    const awarenessCallback = ({ added, removed }: { added: number[]; removed: number[] }): void => {
-      added.forEach((addedId) => {
-        const state = awareness.getStates().get(addedId) as UserAwarenessState | undefined
-        if (!state) {
-          logger.debug('Could not find state for user')
-          return
-        }
-        logger.debug(`added awareness ${addedId}`, state.user)
-        addOnlineUser(addedId, {
-          active: ActiveIndicatorStatus.ACTIVE,
-          color: state.user.color,
-          username: state.user.name
-        })
-      })
-      removed.forEach((removedId) => {
-        logger.debug(`remove awareness ${removedId}`)
-        removeOnlineUser(removedId)
-      })
-    }
-    awareness.on('change', awarenessCallback)
-
-    return () => {
-      awareness.off('change', awarenessCallback)
-      removeOnlineUser(ownAwarenessClientId)
-    }
-  }, [awareness, ownUsername])
-  return awareness
-}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts
index 7437fec08..f7181cb6c 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts
@@ -12,8 +12,11 @@ import type { YText } from 'yjs/dist/src/types/YText'
  *
  * @param yText The source text
  */
-export const useBindYTextToRedux = (yText: YText): void => {
+export const useBindYTextToRedux = (yText: YText | undefined): void => {
   useEffect(() => {
+    if (!yText) {
+      return
+    }
     const yTextCallback = () => setNoteContent(yText.toString())
     yText.observe(yTextCallback)
     return () => yText.unobserve(yTextCallback)
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts
index 7dcf30b03..5825354d3 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts
@@ -3,19 +3,35 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import { useApplicationState } from '../../../../../hooks/common/use-application-state'
+import { YTextSyncViewPlugin } from '../../codemirror-extensions/document-sync/y-text-sync-view-plugin'
 import type { Extension } from '@codemirror/state'
-import { useMemo } from 'react'
-import { yCollab } from 'y-codemirror.next'
-import type { Awareness } from 'y-protocols/awareness'
-import type { YText } from 'yjs/dist/src/types/YText'
+import { ViewPlugin } from '@codemirror/view'
+import type { YDocSyncClientAdapter } from '@hedgedoc/commons'
+import { useEffect, useMemo, useState } from 'react'
+import type { Text as YText } from 'yjs'
 
 /**
- * Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext} and {@link Awareness awareness}.
+ * Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext}.
  *
  * @param yText The source and target for the editor content
- * @param awareness Contains cursor positions and names from other clients that will be shown
+ * @param syncAdapter The sync adapter that processes the communication for content synchronisation.
  * @return the created extension
  */
-export const useCodeMirrorYjsExtension = (yText: YText, awareness: Awareness): Extension => {
-  return useMemo(() => yCollab(yText, awareness), [awareness, yText])
+export const useCodeMirrorYjsExtension = (yText: YText | undefined, syncAdapter: YDocSyncClientAdapter): Extension => {
+  const [editorReady, setEditorReady] = useState(false)
+  const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced)
+  const connected = useApplicationState((state) => state.realtimeStatus.isConnected)
+
+  useEffect(() => {
+    if (editorReady && connected && !synchronized && yText) {
+      syncAdapter.requestDocumentState()
+    }
+  }, [connected, editorReady, syncAdapter, synchronized, yText])
+
+  return useMemo(
+    () =>
+      yText ? [ViewPlugin.define((view) => new YTextSyncViewPlugin(view, yText, () => setEditorReady(true)))] : [],
+    [yText]
+  )
 }
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts
deleted file mode 100644
index 1da56473f..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { getGlobalState } from '../../../../../redux'
-import { isMockMode } from '../../../../../utils/test-modes'
-import { MockConnection } from './mock-connection'
-import type { YDocMessageTransporter } from '@hedgedoc/commons'
-import { useEffect } from 'react'
-
-/**
- * When in mock mode this effect inserts the current markdown content into the yDoc of the given connection to simulate a sync from the server.
- * This should happen only one time because after that the editor writes its changes into the yText which writes it into the redux.
- *
- * Usually the CodeMirror gets its content from yjs sync via websocket. But in mock mode this connection isn't available.
- * That's why this hook inserts the current markdown content, that is currently saved in the global application state
- * and was saved there by the {@link NoteLoadingBoundary note loading boundary}, into the y-text to write it into the codemirror.
- * This has to be done AFTER the CodeMirror sync extension (yCollab) has been loaded because the extension reacts only to updates of the yText
- * and doesn't write the existing content into the editor when being loaded.
- *
- * @param connection The connection into whose yDoc the content should be written
- * @param firstUpdateHappened Defines if the first update already happened
- */
-export const useInsertNoteContentIntoYTextInMockModeEffect = (
-  firstUpdateHappened: boolean,
-  connection: YDocMessageTransporter
-): void => {
-  useEffect(() => {
-    if (firstUpdateHappened && isMockMode && connection instanceof MockConnection) {
-      connection.simulateFirstSync(getGlobalState().noteDetails.markdownContent.plain)
-    }
-  }, [firstUpdateHappened, connection])
-}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts
deleted file mode 100644
index 82c7ed408..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import type { YDocMessageTransporter } from '@hedgedoc/commons'
-import { useEffect, useState } from 'react'
-
-/**
- * Checks if the given message transporter has received at least one full synchronisation.
- *
- * @param connection The connection whose sync status should be checked
- * @return If at least one full synchronisation is occurred.
- */
-export const useIsConnectionSynced = (connection: YDocMessageTransporter): boolean => {
-  const [editorEnabled, setEditorEnabled] = useState<boolean>(false)
-
-  useEffect(() => {
-    const enableEditor = () => setEditorEnabled(true)
-    const disableEditor = () => setEditorEnabled(false)
-    connection.on('synced', enableEditor)
-    connection.on('disconnected', disableEditor)
-    return () => {
-      connection.off('synced', enableEditor)
-      connection.off('disconnected', disableEditor)
-    }
-  }, [connection])
-
-  return editorEnabled
-}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts
index ccf74abf0..7124c3349 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts
@@ -3,9 +3,8 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import { MARKDOWN_CONTENT_CHANNEL_NAME } from '@hedgedoc/commons'
+import type { RealtimeDoc } from '@hedgedoc/commons'
 import { useMemo } from 'react'
-import type { Doc } from 'yjs'
 import type { Text as YText } from 'yjs'
 
 /**
@@ -14,6 +13,6 @@ import type { Text as YText } from 'yjs'
  * @param yDoc The yjs document from which the yText should be extracted
  * @return the extracted yText channel
  */
-export const useMarkdownContentYText = (yDoc: Doc): YText => {
-  return useMemo(() => yDoc.getText(MARKDOWN_CONTENT_CHANNEL_NAME), [yDoc])
+export const useMarkdownContentYText = (yDoc: RealtimeDoc | undefined): YText | undefined => {
+  return useMemo(() => yDoc?.getMarkdownContentChannel(), [yDoc])
 }
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts
deleted file mode 100644
index d6ff59bcf..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import type { Extension } from '@codemirror/state'
-import { EditorView } from '@codemirror/view'
-import { useMemo, useState } from 'react'
-
-/**
- * Provides an extension that checks when the code mirror, that loads the extension, has its first update.
- *
- * @return The extension that listens for editor updates and a boolean that defines if the first update already happened
- */
-export const useOnFirstEditorUpdateExtension = (): [Extension, boolean] => {
-  const [firstUpdateHappened, setFirstUpdateHappened] = useState<boolean>(false)
-  const extension = useMemo(() => EditorView.updateListener.of(() => setFirstUpdateHappened(true)), [])
-  return [extension, firstUpdateHappened]
-}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts
index 0325e4cf5..23540a83f 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts
@@ -4,24 +4,23 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { updateMetadata } from '../../../../../redux/note-details/methods'
-import type { YDocMessageTransporter } from '@hedgedoc/commons'
+import type { MessageTransporter } from '@hedgedoc/commons'
 import { MessageType } from '@hedgedoc/commons'
-import { useCallback, useEffect } from 'react'
+import type { Listener } from 'eventemitter2'
+import { useEffect } from 'react'
 
 /**
  * Hook that updates the metadata if the server announced an update of the metadata.
  *
  * @param websocketConnection The websocket connection that emits the metadata changed event
  */
-export const useOnMetadataUpdated = (websocketConnection: YDocMessageTransporter): void => {
-  const updateMetadataHandler = useCallback(async () => {
-    await updateMetadata()
-  }, [])
-
+export const useOnMetadataUpdated = (websocketConnection: MessageTransporter): void => {
   useEffect(() => {
-    websocketConnection.on(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler())
+    const listener = websocketConnection.on(MessageType.METADATA_UPDATED, () => void updateMetadata(), {
+      objectify: true
+    }) as Listener
     return () => {
-      websocketConnection.off(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler())
+      listener.off()
     }
-  })
+  }, [websocketConnection])
 }
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts
index 022512807..992801c9f 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts
@@ -6,8 +6,9 @@
 import { useApplicationState } from '../../../../../hooks/common/use-application-state'
 import { Logger } from '../../../../../utils/logger'
 import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
-import type { YDocMessageTransporter } from '@hedgedoc/commons'
+import type { MessageTransporter } from '@hedgedoc/commons'
 import { MessageType } from '@hedgedoc/commons'
+import type { Listener } from 'eventemitter2'
 import { useRouter } from 'next/router'
 import { useCallback, useEffect } from 'react'
 
@@ -18,7 +19,7 @@ const logger = new Logger('UseOnNoteDeleted')
  *
  * @param websocketConnection The websocket connection that emits the deletion event
  */
-export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): void => {
+export const useOnNoteDeleted = (websocketConnection: MessageTransporter): void => {
   const router = useRouter()
   const noteTitle = useApplicationState((state) => state.noteDetails.title)
   const { dispatchUiNotification } = useUiNotifications()
@@ -35,9 +36,11 @@ export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): v
   }, [router, noteTitle, dispatchUiNotification])
 
   useEffect(() => {
-    websocketConnection.on(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler)
+    const listener = websocketConnection.on(MessageType.DOCUMENT_DELETED, noteDeletedHandler, {
+      objectify: true
+    }) as Listener
     return () => {
-      websocketConnection.off(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler)
+      listener.off()
     }
-  })
+  }, [noteDeletedHandler, websocketConnection])
 }
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts
new file mode 100644
index 000000000..aab476948
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts
@@ -0,0 +1,110 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { useApplicationState } from '../../../../../hooks/common/use-application-state'
+import { getGlobalState } from '../../../../../redux'
+import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
+import { Logger } from '../../../../../utils/logger'
+import { isMockMode } from '../../../../../utils/test-modes'
+import { useWebsocketUrl } from './use-websocket-url'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons'
+import type { Listener } from 'eventemitter2'
+import WebSocket from 'isomorphic-ws'
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+
+const logger = new Logger('websocket connection')
+const WEBSOCKET_RECONNECT_INTERVAL = 3000
+
+/**
+ * Creates a {@link WebsocketTransporter websocket message transporter} that handles the realtime communication with the backend.
+ *
+ * @return the created connection handler
+ */
+export const useRealtimeConnection = (): MessageTransporter => {
+  const websocketUrl = useWebsocketUrl()
+  const messageTransporter = useMemo(() => {
+    if (isMockMode) {
+      logger.debug('Creating Loopback connection...')
+      return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
+    } else {
+      logger.debug('Creating Websocket connection...')
+      return new WebsocketTransporter()
+    }
+  }, [])
+
+  const establishWebsocketConnection = useCallback(() => {
+    if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
+      logger.debug(`Connecting to ${websocketUrl.toString()}`)
+      const socket = new WebSocket(websocketUrl)
+      socket.addEventListener('error', () => {
+        setTimeout(() => {
+          establishWebsocketConnection()
+        }, WEBSOCKET_RECONNECT_INTERVAL)
+      })
+      socket.addEventListener('open', () => {
+        messageTransporter.setWebsocket(socket)
+      })
+    }
+  }, [messageTransporter, websocketUrl])
+
+  const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
+  const firstConnect = useRef(true)
+
+  const reconnectTimeout = useRef<number | undefined>(undefined)
+
+  useEffect(() => {
+    if (isConnected) {
+      return
+    }
+    if (firstConnect.current) {
+      establishWebsocketConnection()
+      firstConnect.current = false
+    } else {
+      reconnectTimeout.current = window.setTimeout(() => {
+        establishWebsocketConnection()
+      }, WEBSOCKET_RECONNECT_INTERVAL)
+    }
+  }, [establishWebsocketConnection, isConnected, messageTransporter])
+
+  useEffect(() => {
+    const readyListener = messageTransporter.doAsSoonAsReady(() => {
+      const timerId = reconnectTimeout.current
+      if (timerId !== undefined) {
+        window.clearTimeout(timerId)
+      }
+      reconnectTimeout.current = undefined
+    })
+
+    messageTransporter.on('connected', () => logger.debug(`Connected`))
+    messageTransporter.on('disconnected', () => logger.debug(`Disconnected`))
+
+    return () => {
+      const interval = reconnectTimeout.current
+      interval && window.clearTimeout(interval)
+      readyListener.off()
+    }
+  }, [messageTransporter])
+
+  useEffect(() => {
+    const disconnectCallback = () => messageTransporter.disconnect()
+    window.addEventListener('beforeunload', disconnectCallback)
+    return () => window.removeEventListener('beforeunload', disconnectCallback)
+  }, [messageTransporter])
+
+  useEffect(() => {
+    const connectedListener = messageTransporter.doAsSoonAsConnected(() => setRealtimeConnectionState(true))
+    const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), {
+      objectify: true
+    }) as Listener
+
+    return () => {
+      connectedListener.off()
+      disconnectedListener.off()
+    }
+  }, [messageTransporter])
+
+  return messageTransporter
+}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts
new file mode 100644
index 000000000..e409e3947
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { useApplicationState } from '../../../../../hooks/common/use-application-state'
+import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { MessageType } from '@hedgedoc/commons'
+import type { Listener } from 'eventemitter2'
+import { useEffect } from 'react'
+
+/**
+ * Waits for remote cursor updates that are sent from the backend and saves them in the global application state.
+ *
+ * @param messageTransporter the {@link MessageTransporter} that should be used to receive the remote cursor updates
+ */
+export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter): void => {
+  const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
+
+  useEffect(() => {
+    const listener = messageTransporter.on(
+      MessageType.REALTIME_USER_STATE_SET,
+      (payload) => setRealtimeUsers(payload.payload),
+      { objectify: true }
+    ) as Listener
+
+    return () => {
+      listener.off()
+    }
+  }, [messageTransporter])
+
+  useEffect(() => {
+    if (isConnected) {
+      messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
+    }
+  }, [isConnected, messageTransporter])
+}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts
deleted file mode 100644
index 1442f4764..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { isMockMode } from '../../../../../utils/test-modes'
-import { MockConnection } from './mock-connection'
-import { useWebsocketUrl } from './use-websocket-url'
-import { WebsocketConnection } from './websocket-connection'
-import type { YDocMessageTransporter } from '@hedgedoc/commons'
-import { useEffect, useMemo } from 'react'
-import type { Awareness } from 'y-protocols/awareness'
-import type { Doc } from 'yjs'
-
-/**
- * Creates a {@link WebsocketConnection websocket connection handler } that handles the realtime communication with the backend.
- *
- * @param yDoc The {@link Doc y-doc} that should be synchronized with the backend
- * @param awareness The {@link Awareness awareness} that should be synchronized with the backend.
- * @return the created connection handler
- */
-export const useWebsocketConnection = (yDoc: Doc, awareness: Awareness): YDocMessageTransporter => {
-  const websocketUrl = useWebsocketUrl()
-
-  const websocketConnection: YDocMessageTransporter = useMemo(() => {
-    return isMockMode ? new MockConnection(yDoc, awareness) : new WebsocketConnection(websocketUrl, yDoc, awareness)
-  }, [awareness, websocketUrl, yDoc])
-
-  useEffect(() => {
-    const disconnectCallback = () => websocketConnection.disconnect()
-    window.addEventListener('beforeunload', disconnectCallback)
-    return () => {
-      window.removeEventListener('beforeunload', disconnectCallback)
-      disconnectCallback()
-    }
-  }, [websocketConnection])
-
-  return websocketConnection
-}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts
index ad5930690..1eec7c444 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts
@@ -13,7 +13,7 @@ const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/'
 /**
  * Provides the URL for the realtime endpoint.
  */
-export const useWebsocketUrl = (): URL => {
+export const useWebsocketUrl = (): URL | undefined => {
   const noteId = useApplicationState((state) => state.noteDetails.id)
   const baseUrl = useBaseUrl()
 
@@ -33,6 +33,9 @@ export const useWebsocketUrl = (): URL => {
   }, [baseUrl])
 
   return useMemo(() => {
+    if (noteId === '') {
+      return
+    }
     const url = new URL(websocketUrl)
     url.search = `?noteId=${noteId}`
     return url
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts
new file mode 100644
index 000000000..60d47477f
--- /dev/null
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { setRealtimeSyncedState } from '../../../../../redux/realtime/methods'
+import { Logger } from '../../../../../utils/logger'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { YDocSyncClientAdapter } from '@hedgedoc/commons'
+import type { Listener } from 'eventemitter2'
+import { useEffect, useMemo } from 'react'
+import type { Doc } from 'yjs'
+
+const logger = new Logger('useYDocSyncClient')
+
+/**
+ * Creates a {@link YDocSyncClientAdapter} and mirrors its sync state to the global application state.
+ *
+ * @param messageTransporter The {@link MessageTransporter message transporter} that sends and receives messages for the synchronisation
+ * @param yDoc The {@link Doc y-doc} that should be synchronized
+ * @return the created adapter
+ */
+export const useYDocSyncClientAdapter = (
+  messageTransporter: MessageTransporter,
+  yDoc: Doc | undefined
+): YDocSyncClientAdapter => {
+  const syncAdapter = useMemo(() => new YDocSyncClientAdapter(messageTransporter), [messageTransporter])
+
+  useEffect(() => {
+    syncAdapter.setYDoc(yDoc)
+  }, [syncAdapter, yDoc])
+
+  useEffect(() => {
+    const onceSyncedListener = syncAdapter.doAsSoonAsSynced(() => {
+      logger.debug('YDoc synced')
+      setRealtimeSyncedState(true)
+    })
+    const desyncedListener = syncAdapter.eventEmitter.on(
+      'desynced',
+      () => {
+        logger.debug('YDoc de-synced')
+        setRealtimeSyncedState(false)
+      },
+      {
+        objectify: true
+      }
+    ) as Listener
+
+    return () => {
+      onceSyncedListener.off()
+      desyncedListener.off()
+    }
+  }, [messageTransporter, syncAdapter])
+
+  return syncAdapter
+}
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts
index 89c3d1bae..5501d50ce 100644
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts
+++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts
@@ -3,16 +3,28 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import { useEffect, useMemo } from 'react'
-import { Doc } from 'yjs'
+import type { MessageTransporter } from '@hedgedoc/commons'
+import { RealtimeDoc } from '@hedgedoc/commons'
+import { useEffect, useState } from 'react'
 
 /**
- * Creates a new {@link Doc y-doc}.
+ * Creates a new {@link RealtimeDoc y-doc}.
  *
- * @return The created {@link Doc y-doc}
+ * @return The created {@link RealtimeDoc y-doc}
  */
-export const useYDoc = (): Doc => {
-  const yDoc = useMemo(() => new Doc(), [])
-  useEffect(() => () => yDoc.destroy(), [yDoc])
+export const useYDoc = (messageTransporter: MessageTransporter): RealtimeDoc | undefined => {
+  const [yDoc, setYDoc] = useState<RealtimeDoc>()
+
+  useEffect(() => {
+    messageTransporter.doAsSoonAsConnected(() => {
+      setYDoc(new RealtimeDoc())
+    })
+    messageTransporter.on('disconnected', () => {
+      setYDoc(undefined)
+    })
+  }, [messageTransporter])
+
+  useEffect(() => () => yDoc?.destroy(), [yDoc])
+
   return yDoc
 }
diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts
deleted file mode 100644
index ec6e6cfa1..000000000
--- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import {
-  encodeAwarenessUpdateMessage,
-  encodeCompleteAwarenessStateRequestMessage,
-  encodeDocumentUpdateMessage,
-  WebsocketTransporter
-} from '@hedgedoc/commons'
-import WebSocket from 'isomorphic-ws'
-import type { Awareness } from 'y-protocols/awareness'
-import type { Doc } from 'yjs'
-
-/**
- * Handles the communication with the realtime endpoint of the backend and synchronizes the given y-doc and awareness with other clients.
- */
-export class WebsocketConnection extends WebsocketTransporter {
-  constructor(url: URL, doc: Doc, awareness: Awareness) {
-    super(doc, awareness)
-    this.bindYDocEvents(doc)
-    this.bindAwarenessMessageEvents(awareness)
-    const websocket = new WebSocket(url)
-    this.setupWebsocket(websocket)
-  }
-
-  private bindAwarenessMessageEvents(awareness: Awareness) {
-    const updateCallback = (
-      { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
-      origin: unknown
-    ) => {
-      if (origin !== this) {
-        this.send(encodeAwarenessUpdateMessage(awareness, [...added, ...updated, ...removed]))
-      }
-    }
-    this.on('disconnected', () => {
-      awareness.off('update', updateCallback)
-      awareness.destroy()
-    })
-
-    this.on('ready', () => {
-      awareness.on('update', updateCallback)
-    })
-    this.on('synced', () => {
-      this.send(encodeCompleteAwarenessStateRequestMessage())
-      this.send(encodeAwarenessUpdateMessage(awareness, [awareness.doc.clientID]))
-    })
-  }
-
-  private bindYDocEvents(doc: Doc): void {
-    doc.on('destroy', () => this.disconnect())
-    doc.on('update', (update: Uint8Array, origin: unknown) => {
-      if (origin !== this && this.isSynced() && this.isWebSocketOpen()) {
-        this.send(encodeDocumentUpdateMessage(update))
-      }
-    })
-  }
-}
diff --git a/frontend/src/components/editor-page/reset-realtime-state-boundary.tsx b/frontend/src/components/editor-page/reset-realtime-state-boundary.tsx
new file mode 100644
index 000000000..af52e21ce
--- /dev/null
+++ b/frontend/src/components/editor-page/reset-realtime-state-boundary.tsx
@@ -0,0 +1,27 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { resetRealtimeStatus } from '../../redux/realtime/methods'
+import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
+import type { PropsWithChildren } from 'react'
+import React, { Fragment, useEffect, useState } from 'react'
+
+/**
+ * Resets the realtime status in the global application state to its initial state before loading the given child elements.
+ *
+ * @param children The children to load after the reset
+ */
+export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
+  const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
+  useEffect(() => {
+    resetRealtimeStatus()
+    setGlobalStateInitialized(true)
+  }, [])
+  if (!globalStateInitialized) {
+    return <LoadingScreen />
+  } else {
+    return <Fragment>{children}</Fragment>
+  }
+}
diff --git a/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss b/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss
index 5b2ef46f6..31cdede99 100644
--- a/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss
+++ b/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss
@@ -5,7 +5,7 @@
  */
 
 .user-line-color-indicator {
-  border-left: 3px solid;
+  border-left: 3px solid var(--color);
   min-height: 30px;
   height: 100%;
   flex: 0 0 3px;
diff --git a/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx b/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx
index 5815746b3..7f07cc333 100644
--- a/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx
+++ b/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx
@@ -3,16 +3,16 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
 import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
+import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class'
 import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator'
 import styles from './user-line.module.scss'
 import React from 'react'
 
 export interface UserLineProps {
   username: string | null
-  color: string
-  status: ActiveIndicatorStatus
+  active: boolean
+  color: number
 }
 
 /**
@@ -22,19 +22,20 @@ export interface UserLineProps {
  * @param color The color of the user's edits.
  * @param status The user's current online status.
  */
-export const UserLine: React.FC<UserLineProps> = ({ username, color, status }) => {
+export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) => {
   return (
     <div className={'d-flex align-items-center h-100 w-100'}>
       <div
-        className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']}`}
-        style={{ borderLeftColor: color }}
+        className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']} ${createCursorCssClass(
+          color
+        )}`}
       />
       <UserAvatarForUsername
         username={username}
         additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
       />
       <div className={styles['active-indicator-container']}>
-        <ActiveIndicator status={status} />
+        <ActiveIndicator active={active} />
       </div>
     </div>
   )
diff --git a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx
index 758eb2f4f..03e86a3b5 100644
--- a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx
+++ b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx
@@ -3,12 +3,11 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
 import styles from './active-indicator.module.scss'
 import React from 'react'
 
 export interface ActiveIndicatorProps {
-  status: ActiveIndicatorStatus
+  active: boolean
 }
 
 /**
@@ -16,6 +15,6 @@ export interface ActiveIndicatorProps {
  *
  * @param status The state of the indicator to render
  */
-export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ status }) => {
-  return <span className={`${styles['activeIndicator']} ${status}`} />
+export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ active }) => {
+  return <span className={`${styles['activeIndicator']} ${active ? styles.active : styles.inactive}`} />
 }
diff --git a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx
index 040928bd7..179e33fd2 100644
--- a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx
+++ b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx
@@ -31,21 +31,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
   selectedMenuId
 }) => {
   const buttonRef = useRef<HTMLButtonElement>(null)
-  const onlineUsers = useApplicationState((state) => state.realtime.users)
+  const realtimeUsers = useApplicationState((state) => state.realtimeStatus.onlineUsers)
   useTranslation()
 
   useEffect(() => {
-    const value = `${Object.keys(onlineUsers).length}`
-    buttonRef.current?.style.setProperty('--users-online', `"${value}"`)
-  }, [onlineUsers])
+    buttonRef.current?.style.setProperty('--users-online', `"${realtimeUsers.length}"`)
+  }, [realtimeUsers])
 
   const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId
   const expand = selectedMenuId === menuId
   const onClickHandler = useCallback(() => onClick(menuId), [menuId, onClick])
 
   const onlineUserElements = useMemo(() => {
-    const entries = Object.entries(onlineUsers)
-    if (entries.length === 0) {
+    if (realtimeUsers.length === 0) {
       return (
         <SidebarButton>
           <span className={'ms-3'}>
@@ -54,15 +52,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
         </SidebarButton>
       )
     } else {
-      return entries.map(([clientId, onlineUser]) => {
+      return realtimeUsers.map((realtimeUser) => {
         return (
-          <SidebarButton key={clientId}>
-            <UserLine username={onlineUser.username} color={onlineUser.color} status={onlineUser.active} />
+          <SidebarButton key={realtimeUser.styleIndex}>
+            <UserLine
+              username={realtimeUser.displayName}
+              color={realtimeUser.styleIndex}
+              active={realtimeUser.active}
+            />
           </SidebarButton>
         )
       })
     }
-  }, [onlineUsers])
+  }, [realtimeUsers])
 
   return (
     <Fragment>
diff --git a/frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx b/frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx
new file mode 100644
index 000000000..3b0ed00b4
--- /dev/null
+++ b/frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { useApplicationState } from '../../../hooks/common/use-application-state'
+import { WaitSpinner } from '../../common/wait-spinner/wait-spinner'
+import React from 'react'
+import { Col, Container, Modal, Row } from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+
+/**
+ * Modal with a spinner that is only shown while reconnecting to the realtime backend
+ */
+export const RealtimeConnectionModal: React.FC = () => {
+  const isConnected = useApplicationState((state) => state.realtimeStatus.isSynced)
+  useTranslation()
+
+  return (
+    <Modal show={!isConnected}>
+      <Modal.Body>
+        <Container className={'text-center'}>
+          <Row className={'mb-4'}>
+            <Col xs={12}>
+              <WaitSpinner size={5}></WaitSpinner>
+            </Col>
+          </Row>
+          <Row>
+            <Col xs={12}>
+              <span>
+                <Trans i18nKey={'realtime.reconnect'}></Trans>
+              </span>
+            </Col>
+          </Row>
+        </Container>
+      </Modal.Body>
+    </Modal>
+  )
+}
diff --git a/frontend/src/pages/n/[noteId].tsx b/frontend/src/pages/n/[noteId].tsx
index e7a4881be..cf39ebc0c 100644
--- a/frontend/src/pages/n/[noteId].tsx
+++ b/frontend/src/pages/n/[noteId].tsx
@@ -6,6 +6,7 @@
 import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
 import { EditorPageContent } from '../../components/editor-page/editor-page-content'
 import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
+import { ResetRealtimeStateBoundary } from '../../components/editor-page/reset-realtime-state-boundary'
 import type { NextPage } from 'next'
 import React from 'react'
 
@@ -14,11 +15,13 @@ import React from 'react'
  */
 export const EditorPage: NextPage = () => {
   return (
-    <NoteLoadingBoundary>
-      <EditorToRendererCommunicatorContextProvider>
-        <EditorPageContent />
-      </EditorToRendererCommunicatorContextProvider>
-    </NoteLoadingBoundary>
+    <ResetRealtimeStateBoundary>
+      <NoteLoadingBoundary>
+        <EditorToRendererCommunicatorContextProvider>
+          <EditorPageContent />
+        </EditorToRendererCommunicatorContextProvider>
+      </NoteLoadingBoundary>
+    </ResetRealtimeStateBoundary>
   )
 }
 
diff --git a/frontend/src/redux/application-state.d.ts b/frontend/src/redux/application-state.d.ts
index 761b2d3d9..358935d36 100644
--- a/frontend/src/redux/application-state.d.ts
+++ b/frontend/src/redux/application-state.d.ts
@@ -8,7 +8,7 @@ import type { HistoryEntryWithOrigin } from '../api/history/types'
 import type { DarkModeConfig } from './dark-mode/types'
 import type { EditorConfig } from './editor/types'
 import type { NoteDetails } from './note-details/types/note-details'
-import type { RealtimeState } from './realtime/types'
+import type { RealtimeStatus } from './realtime/types'
 import type { RendererStatus } from './renderer-status/types'
 import type { OptionalUserState } from './user/types'
 
@@ -20,5 +20,5 @@ export interface ApplicationState {
   darkMode: DarkModeConfig
   noteDetails: NoteDetails
   rendererStatus: RendererStatus
-  realtime: RealtimeState
+  realtimeStatus: RealtimeStatus
 }
diff --git a/frontend/src/redux/realtime/methods.ts b/frontend/src/redux/realtime/methods.ts
index 836859c2c..39011435f 100644
--- a/frontend/src/redux/realtime/methods.ts
+++ b/frontend/src/redux/realtime/methods.ts
@@ -4,33 +4,37 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { store } from '..'
-import type { AddOnlineUserAction, OnlineUser, RemoveOnlineUserAction } from './types'
-import { RealtimeActionType } from './types'
+import type { SetRealtimeSyncStatusAction, SetRealtimeUsersAction, SetRealtimeConnectionStatusAction } from './types'
+import { RealtimeStatusActionType } from './types'
+import type { RealtimeUser } from '@hedgedoc/commons'
 
 /**
  * Dispatches an event to add a user
- *
- * @param clientId The clientId of the user to add
- * @param user The user to add.
  */
-export const addOnlineUser = (clientId: number, user: OnlineUser): void => {
-  const action: AddOnlineUserAction = {
-    type: RealtimeActionType.ADD_ONLINE_USER,
-    clientId,
-    user
+export const setRealtimeUsers = (users: RealtimeUser[]): void => {
+  const action: SetRealtimeUsersAction = {
+    type: RealtimeStatusActionType.SET_REALTIME_USERS,
+    users
   }
   store.dispatch(action)
 }
 
-/**
- * Dispatches an event to remove a user from the online users list.
- *
- * @param clientId The yjs client id of the user to remove from the online users list.
- */
-export const removeOnlineUser = (clientId: number): void => {
-  const action: RemoveOnlineUserAction = {
-    type: RealtimeActionType.REMOVE_ONLINE_USER,
-    clientId
-  }
-  store.dispatch(action)
+export const setRealtimeConnectionState = (status: boolean): void => {
+  store.dispatch({
+    type: RealtimeStatusActionType.SET_REALTIME_CONNECTION_STATUS,
+    isConnected: status
+  } as SetRealtimeConnectionStatusAction)
+}
+
+export const setRealtimeSyncedState = (status: boolean): void => {
+  store.dispatch({
+    type: RealtimeStatusActionType.SET_REALTIME_SYNCED_STATUS,
+    isSynced: status
+  } as SetRealtimeSyncStatusAction)
+}
+
+export const resetRealtimeStatus = (): void => {
+  store.dispatch({
+    type: RealtimeStatusActionType.RESET_REALTIME_STATUS
+  })
 }
diff --git a/frontend/src/redux/realtime/reducers.ts b/frontend/src/redux/realtime/reducers.ts
index debad5169..50655c6d6 100644
--- a/frontend/src/redux/realtime/reducers.ts
+++ b/frontend/src/redux/realtime/reducers.ts
@@ -3,32 +3,45 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import { buildStateFromAddUser } from './reducers/build-state-from-add-user'
-import { buildStateFromRemoveUser } from './reducers/build-state-from-remove-user'
-import type { RealtimeActions, RealtimeState } from './types'
-import { RealtimeActionType } from './types'
+import type { RealtimeStatusActions, RealtimeStatus } from './types'
+import { RealtimeStatusActionType } from './types'
 import type { Reducer } from 'redux'
 
-const initialState: RealtimeState = {
-  users: []
+const initialState: RealtimeStatus = {
+  isSynced: false,
+  isConnected: false,
+  onlineUsers: []
 }
 
 /**
- * Applies {@link RealtimeReducer realtime actions} to the global application state.
+ * Applies {@link RealtimeStatusReducer realtime actions} to the global application state.
  *
  * @param state the current state
  * @param action the action that should get applied
  * @return The new changed state
  */
-export const RealtimeReducer: Reducer<RealtimeState, RealtimeActions> = (
+export const RealtimeStatusReducer: Reducer<RealtimeStatus, RealtimeStatusActions> = (
   state = initialState,
-  action: RealtimeActions
+  action: RealtimeStatusActions
 ) => {
   switch (action.type) {
-    case RealtimeActionType.ADD_ONLINE_USER:
-      return buildStateFromAddUser(state, action.clientId, action.user)
-    case RealtimeActionType.REMOVE_ONLINE_USER:
-      return buildStateFromRemoveUser(state, action.clientId)
+    case RealtimeStatusActionType.SET_REALTIME_USERS:
+      return {
+        ...state,
+        onlineUsers: action.users
+      }
+    case RealtimeStatusActionType.SET_REALTIME_CONNECTION_STATUS:
+      return {
+        ...state,
+        isConnected: action.isConnected
+      }
+    case RealtimeStatusActionType.SET_REALTIME_SYNCED_STATUS:
+      return {
+        ...state,
+        isSynced: action.isSynced
+      }
+    case RealtimeStatusActionType.RESET_REALTIME_STATUS:
+      return initialState
     default:
       return state
   }
diff --git a/frontend/src/redux/realtime/reducers/build-state-from-add-user.ts b/frontend/src/redux/realtime/reducers/build-state-from-add-user.ts
deleted file mode 100644
index 74b584d00..000000000
--- a/frontend/src/redux/realtime/reducers/build-state-from-add-user.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import type { OnlineUser, RealtimeState } from '../types'
-
-/**
- * Builds a new {@link RealtimeState} with a new client id that is shown as online.
- *
- * @param oldState The old state that will be copied
- * @param clientId The identifier of the new client
- * @param user The information about the new user
- * @return the generated state
- */
-export const buildStateFromAddUser = (oldState: RealtimeState, clientId: number, user: OnlineUser): RealtimeState => {
-  return {
-    users: {
-      ...oldState.users,
-      [clientId]: user
-    }
-  }
-}
diff --git a/frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts b/frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts
deleted file mode 100644
index 964f3b965..000000000
--- a/frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import type { RealtimeState } from '../types'
-
-/**
- * Builds a new {@link RealtimeState} but removes the information about a client.
- *
- * @param oldState The old state that will be copied
- * @param clientIdToRemove The identifier of the client that should be removed
- * @return the generated state
- */
-export const buildStateFromRemoveUser = (oldState: RealtimeState, clientIdToRemove: number): RealtimeState => {
-  const newUsers = { ...oldState.users }
-  delete newUsers[clientIdToRemove]
-  return {
-    users: newUsers
-  }
-}
diff --git a/frontend/src/redux/realtime/types.ts b/frontend/src/redux/realtime/types.ts
index 40983f9bc..c09199036 100644
--- a/frontend/src/redux/realtime/types.ts
+++ b/frontend/src/redux/realtime/types.ts
@@ -3,38 +3,43 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import type { RealtimeUser } from '@hedgedoc/commons'
 import type { Action } from 'redux'
 
-export enum RealtimeActionType {
-  ADD_ONLINE_USER = 'realtime/add-user',
-  REMOVE_ONLINE_USER = 'realtime/remove-user',
-  UPDATE_ONLINE_USER = 'realtime/update-user'
+export enum RealtimeStatusActionType {
+  SET_REALTIME_USERS = 'realtime/set-users',
+  SET_REALTIME_CONNECTION_STATUS = 'realtime/set-connection-status',
+  SET_REALTIME_SYNCED_STATUS = 'realtime/set-synced-status',
+  RESET_REALTIME_STATUS = 'realtime/reset-realtime-status'
 }
 
-export interface RealtimeState {
-  users: Record<number, OnlineUser>
+export interface SetRealtimeUsersAction extends Action<RealtimeStatusActionType> {
+  type: RealtimeStatusActionType.SET_REALTIME_USERS
+  users: RealtimeUser[]
 }
 
-export enum ActiveIndicatorStatus {
-  ACTIVE = 'active',
-  INACTIVE = 'inactive'
+export interface SetRealtimeConnectionStatusAction extends Action<RealtimeStatusActionType> {
+  type: RealtimeStatusActionType.SET_REALTIME_CONNECTION_STATUS
+  isConnected: boolean
 }
 
-export interface OnlineUser {
-  username: string
-  color: string
-  active: ActiveIndicatorStatus
+export interface SetRealtimeSyncStatusAction extends Action<RealtimeStatusActionType> {
+  type: RealtimeStatusActionType.SET_REALTIME_SYNCED_STATUS
+  isSynced: boolean
 }
 
-export interface AddOnlineUserAction extends Action<RealtimeActionType> {
-  type: RealtimeActionType.ADD_ONLINE_USER
-  clientId: number
-  user: OnlineUser
+export interface ResetRealtimeStatusAction extends Action<RealtimeStatusActionType> {
+  type: RealtimeStatusActionType.RESET_REALTIME_STATUS
 }
 
-export interface RemoveOnlineUserAction extends Action<RealtimeActionType> {
-  type: RealtimeActionType.REMOVE_ONLINE_USER
-  clientId: number
+export interface RealtimeStatus {
+  onlineUsers: RealtimeUser[]
+  isConnected: boolean
+  isSynced: boolean
 }
 
-export type RealtimeActions = AddOnlineUserAction | RemoveOnlineUserAction
+export type RealtimeStatusActions =
+  | SetRealtimeUsersAction
+  | SetRealtimeConnectionStatusAction
+  | SetRealtimeSyncStatusAction
+  | ResetRealtimeStatusAction
diff --git a/frontend/src/redux/reducers.ts b/frontend/src/redux/reducers.ts
index 693bec896..9c55ec892 100644
--- a/frontend/src/redux/reducers.ts
+++ b/frontend/src/redux/reducers.ts
@@ -9,7 +9,7 @@ import { DarkModeConfigReducer } from './dark-mode/reducers'
 import { EditorConfigReducer } from './editor/reducers'
 import { HistoryReducer } from './history/reducers'
 import { NoteDetailsReducer } from './note-details/reducer'
-import { RealtimeReducer } from './realtime/reducers'
+import { RealtimeStatusReducer } from './realtime/reducers'
 import { RendererStatusReducer } from './renderer-status/reducers'
 import { UserReducer } from './user/reducers'
 import type { Reducer } from 'redux'
@@ -23,5 +23,5 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
   darkMode: DarkModeConfigReducer,
   noteDetails: NoteDetailsReducer,
   rendererStatus: RendererStatusReducer,
-  realtime: RealtimeReducer
+  realtimeStatus: RealtimeStatusReducer
 })
diff --git a/package.json b/package.json
index f8b9c1bdd..d4990f28a 100644
--- a/package.json
+++ b/package.json
@@ -28,8 +28,6 @@
     "@codemirror/lint": "6.2.0",
     "@codemirror/theme-one-dark": "6.1.1",
     "@types/react": "18.0.28",
-    "y-protocols@^1.0.0": "patch:y-protocols@npm%3A1.0.5#./.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch",
-    "y-protocols@1.0.5": "patch:y-protocols@npm%3A1.0.5#./.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch",
     "eventemitter2@6.4.9": "patch:eventemitter2@npm%3A6.4.9#./.yarn/patches/eventemitter2-npm-6.4.9-ba37798a18.patch"
   },
   "devDependencies": {
diff --git a/yarn.lock b/yarn.lock
index b0a87d2e4..6f987f9a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2253,7 +2253,6 @@ __metadata:
     jest: 29.5.0
     joi: 17.9.1
     ldapauth-fork: 5.0.5
-    lib0: 0.2.73
     minio: 7.0.33
     mocked-env: 1.3.5
     mysql: 2.18.1
@@ -2279,7 +2278,6 @@ __metadata:
     typeorm: 0.3.7
     typescript: 5.0.2
     ws: 8.13.0
-    y-protocols: 1.0.5
     yjs: 13.5.51
   languageName: unknown
   linkType: soft
@@ -2300,13 +2298,11 @@ __metadata:
     eventemitter2: 6.4.9
     isomorphic-ws: 5.0.0
     jest: 29.5.0
-    lib0: 0.2.73
     microbundle: 0.15.1
     prettier: 2.8.7
     ts-jest: 29.0.5
     typescript: 5.0.2
     ws: 8.13.0
-    y-protocols: 1.0.5
     yjs: 13.5.51
   languageName: unknown
   linkType: soft
@@ -2451,8 +2447,6 @@ __metadata:
     vega-lite: 5.6.1
     words-count: 2.0.2
     ws: 8.13.0
-    y-codemirror.next: 0.3.2
-    y-protocols: 1.0.5
     yjs: 13.5.51
   languageName: unknown
   linkType: soft
@@ -12473,7 +12467,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lib0@npm:0.2.73":
+"lib0@npm:^0.2.72":
   version: 0.2.73
   resolution: "lib0@npm:0.2.73"
   dependencies:
@@ -12485,30 +12479,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lib0@npm:^0.2.42":
-  version: 0.2.69
-  resolution: "lib0@npm:0.2.69"
-  dependencies:
-    isomorphic.js: ^0.2.4
-  bin:
-    0gentesthtml: bin/gentesthtml.js
-    0serve: bin/0serve.js
-  checksum: bfad469101984c8d1a7d56d7170977494e2c99137603d93950ba14fa6214a38e85e32016c78dd033c80ada88959fb2abd60544cd8eab3bdf0e1a5349635ac585
-  languageName: node
-  linkType: hard
-
-"lib0@npm:^0.2.72":
-  version: 0.2.72
-  resolution: "lib0@npm:0.2.72"
-  dependencies:
-    isomorphic.js: ^0.2.4
-  bin:
-    0gentesthtml: bin/gentesthtml.js
-    0serve: bin/0serve.js
-  checksum: 0fdee00fd5b19687392ac696f34ec9d1702b2c66fbb2d76a4f5e0225f50b10beb4de9b03a0de3826d2887c690b894ea2c874d45709d43d1b654b174d893b90d6
-  languageName: node
-  linkType: hard
-
 "libphonenumber-js@npm:^1.10.14":
   version: 1.10.24
   resolution: "libphonenumber-js@npm:1.10.24"
@@ -19130,37 +19100,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"y-codemirror.next@npm:0.3.2":
-  version: 0.3.2
-  resolution: "y-codemirror.next@npm:0.3.2"
-  dependencies:
-    lib0: ^0.2.42
-  peerDependencies:
-    "@codemirror/state": ^6.0.0
-    "@codemirror/view": ^6.0.0
-    yjs: ^13.5.6
-  checksum: d86cae29fe654ad4e556782c08055eb3e4ee1706cdb1bd7bf5b15c2ce1c91cb5b9d7785c8b0a9ee85444337fd3a0dba84d9d276895114a62dba9dab6d9d05ca0
-  languageName: node
-  linkType: hard
-
-"y-protocols@npm:1.0.5":
-  version: 1.0.5
-  resolution: "y-protocols@npm:1.0.5"
-  dependencies:
-    lib0: ^0.2.42
-  checksum: d19404a4ebafcf3761c28b881abe8c32ab6e457db0e5ffc7dbb749cbc2c3bb98e003a43f3e8eba7f245b2698c76f2c4cdd1c2db869f8ec0c6ef94736d9a88652
-  languageName: node
-  linkType: hard
-
-"y-protocols@patch:y-protocols@npm%3A1.0.5#./.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch::locator=hedgedoc%40workspace%3A.":
-  version: 1.0.5
-  resolution: "y-protocols@patch:y-protocols@npm%3A1.0.5#./.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch::version=1.0.5&hash=74a689&locator=hedgedoc%40workspace%3A."
-  dependencies:
-    lib0: ^0.2.42
-  checksum: 95480e7d8dac9e14cf82473f3a151aee131e0624d4e4920b63c96a91e7fcf3597612852368f62741c7ba3ff8a6aea1d77b7ddca3f4c9426da7dd8f4c6c98b728
-  languageName: node
-  linkType: hard
-
 "y18n@npm:^5.0.5":
   version: 5.0.8
   resolution: "y18n@npm:5.0.8"