diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts
index 48ad651ac..e104829ed 100644
--- a/backend/src/realtime/websocket/websocket.gateway.ts
+++ b/backend/src/realtime/websocket/websocket.gateway.ts
@@ -3,7 +3,11 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import { WebsocketTransporter } from '@hedgedoc/commons';
+import {
+  CborMessageEncoder,
+  JsonMessageEncoder,
+  WebsocketTransporter,
+} from '@hedgedoc/commons';
 import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
 import { IncomingMessage } from 'http';
 import WebSocket from 'ws';
@@ -76,7 +80,12 @@ export class WebsocketGateway implements OnGatewayConnection {
       const realtimeNote =
         await this.realtimeNoteService.getOrCreateRealtimeNote(note);
 
-      const websocketTransporter = new WebsocketTransporter();
+      const messageEncoder =
+        process.env.NODE_ENV === 'development'
+          ? new JsonMessageEncoder()
+          : new CborMessageEncoder();
+
+      const websocketTransporter = new WebsocketTransporter(messageEncoder);
       const connection = new RealtimeConnection(
         websocketTransporter,
         user,
diff --git a/commons/package.json b/commons/package.json
index 68523a0f0..4e49a9700 100644
--- a/commons/package.json
+++ b/commons/package.json
@@ -38,6 +38,7 @@
     "url": "https://github.com/hedgedoc/hedgedoc.git"
   },
   "dependencies": {
+    "cbor-x": "1.5.1",
     "eventemitter2": "6.4.9",
     "isomorphic-ws": "5.0.0",
     "reveal.js": "4.4.0",
diff --git a/commons/src/index.ts b/commons/src/index.ts
index d416c8a20..ce235fe0e 100644
--- a/commons/src/index.ts
+++ b/commons/src/index.ts
@@ -10,6 +10,10 @@ export * from './message-transporters/message-transporter.js'
 export * from './message-transporters/realtime-user.js'
 export * from './message-transporters/websocket-transporter.js'
 
+export * from './message-encoders/message-encoder.js'
+export * from './message-encoders/cbor-message-encoder.js'
+export * from './message-encoders/json-message-encoder.js'
+
 export { parseUrl } from './utils/parse-url.js'
 export {
   MissingTrailingSlashError,
diff --git a/commons/src/message-encoders/cbor-message-encoder.ts b/commons/src/message-encoders/cbor-message-encoder.ts
new file mode 100644
index 000000000..4136abf61
--- /dev/null
+++ b/commons/src/message-encoders/cbor-message-encoder.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 { MessageEncoder } from './message-encoder.js'
+import { Encoder, Decoder } from 'cbor-x'
+
+interface ReceivedMessage {
+  type: number
+  payload: unknown
+}
+
+const keyMap = {
+  type: 0,
+  payload: 1,
+  users: 2,
+  ownUser: 3,
+  displayName: 4,
+  styleIndex: 5,
+  active: 6,
+  username: 7,
+  cursor: 8,
+  from: 9,
+  to: 10
+}
+
+const messageTypes = Object.values(MessageType)
+
+export class CborMessageEncoder implements MessageEncoder {
+  private readonly encoder: Encoder = new Encoder({
+    keyMap
+  })
+  private readonly decoder: Decoder = new Decoder({
+    keyMap
+  })
+
+  encode(message: Message<MessageType>): Uint8Array {
+    const type = messageTypes.indexOf(message.type)
+    return this.encoder.encode({
+      ...message,
+      type
+    })
+  }
+
+  decode(message: ArrayBuffer): Message<MessageType> {
+    const uint8Array = new Uint8Array(message)
+    const decoded = this.decoder.decode(uint8Array) as ReceivedMessage
+    const type = messageTypes[decoded.type]
+    return {
+      ...decoded,
+      type
+    } as Message<MessageType>
+  }
+}
diff --git a/commons/src/message-encoders/json-message-encoder.ts b/commons/src/message-encoders/json-message-encoder.ts
new file mode 100644
index 000000000..d3252c109
--- /dev/null
+++ b/commons/src/message-encoders/json-message-encoder.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { MessageEncoder } from './message-encoder.js'
+
+export class JsonMessageEncoder implements MessageEncoder {
+  public encode(message: Message<MessageType>): string {
+    return JSON.stringify(message)
+  }
+
+  public decode(message: string): Message<MessageType> {
+    return JSON.parse(message) as Message<MessageType>
+  }
+}
diff --git a/commons/src/message-encoders/message-encoder.ts b/commons/src/message-encoders/message-encoder.ts
new file mode 100644
index 000000000..60e557ace
--- /dev/null
+++ b/commons/src/message-encoders/message-encoder.ts
@@ -0,0 +1,13 @@
+/*
+ * 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 WebSocket from 'isomorphic-ws'
+
+export abstract class MessageEncoder {
+  public abstract encode(message: Message<MessageType>): WebSocket.Data
+
+  public abstract decode(message: WebSocket.Data): Message<MessageType>
+}
diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts
index a93c394d4..c9b59fd9b 100644
--- a/commons/src/message-transporters/message.ts
+++ b/commons/src/message-transporters/message.ts
@@ -6,19 +6,22 @@
 import { RealtimeUser, RemoteCursor } from './realtime-user.js'
 
 export enum MessageType {
-  NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST',
+  // This enum is sorted by frequency of usage for efficient binary encoding
+  REALTIME_USER_STATE_SET = 'REALTIME_USER_STATE_SET',
+  REALTIME_USER_SET_ACTIVITY = 'REALTIME_USER_SET_ACTIVITY',
+  REALTIME_USER_SINGLE_UPDATE = 'REALTIME_USER_SINGLE_UPDATE',
   NOTE_CONTENT_UPDATE = 'NOTE_CONTENT_UPDATE',
+
+  NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST',
+  REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST',
+
   PING = 'PING',
   PONG = 'PONG',
+  READY = 'READY',
+
   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',
-  REALTIME_USER_SET_ACTIVITY = 'REALTIME_USER_SET_ACTIVITY',
-
-  READY = 'READY'
+  SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED'
 }
 
 export interface MessagePayloads {
diff --git a/commons/src/message-transporters/websocket-transporter.ts b/commons/src/message-transporters/websocket-transporter.ts
index bf1e629d2..d51125fbc 100644
--- a/commons/src/message-transporters/websocket-transporter.ts
+++ b/commons/src/message-transporters/websocket-transporter.ts
@@ -3,6 +3,7 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import { MessageEncoder } from '../message-encoders/message-encoder.js'
 import { ConnectionState, MessageTransporter } from './message-transporter.js'
 import { Message, MessageType } from './message.js'
 import WebSocket, { MessageEvent } from 'isomorphic-ws'
@@ -13,7 +14,7 @@ export class WebsocketTransporter extends MessageTransporter {
   private messageCallback: undefined | ((event: MessageEvent) => void)
   private closeCallback: undefined | (() => void)
 
-  constructor() {
+  constructor(private readonly encoder: MessageEncoder) {
     super()
   }
 
@@ -57,10 +58,7 @@ export class WebsocketTransporter extends MessageTransporter {
   }
 
   private processMessageEvent(event: MessageEvent): void {
-    if (typeof event.data !== 'string') {
-      return
-    }
-    const message = JSON.parse(event.data) as Message<MessageType>
+    const message = this.encoder.decode(event.data)
     this.receiveMessage(message)
   }
 
@@ -92,7 +90,8 @@ export class WebsocketTransporter extends MessageTransporter {
     }
 
     try {
-      this.websocket.send(JSON.stringify(content))
+      const encoded = this.encoder.encode(content)
+      this.websocket.send(encoded)
     } catch (error: unknown) {
       this.disconnect()
       throw error
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
index dacdd29c6..dc2a0ebc5 100644
--- 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
@@ -7,10 +7,15 @@ import { useApplicationState } from '../../../../../hooks/common/use-application
 import { getGlobalState } from '../../../../../redux'
 import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
 import { Logger } from '../../../../../utils/logger'
-import { isMockMode } from '../../../../../utils/test-modes'
+import { isMockMode, isDevMode } from '../../../../../utils/test-modes'
 import { useWebsocketUrl } from './use-websocket-url'
 import type { MessageTransporter } from '@hedgedoc/commons'
-import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons'
+import {
+  CborMessageEncoder,
+  JsonMessageEncoder,
+  MockedBackendMessageTransporter,
+  WebsocketTransporter
+} from '@hedgedoc/commons'
 import type { Listener } from 'eventemitter2'
 import WebSocket from 'isomorphic-ws'
 import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -31,7 +36,8 @@ export const useRealtimeConnection = (): MessageTransporter => {
       return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
     } else {
       logger.debug('Creating Websocket connection...')
-      return new WebsocketTransporter()
+      const encoder = isDevMode ? new JsonMessageEncoder() : new CborMessageEncoder()
+      return new WebsocketTransporter(encoder)
     }
   }, [])
 
@@ -39,6 +45,7 @@ export const useRealtimeConnection = (): MessageTransporter => {
     if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
       logger.debug(`Connecting to ${websocketUrl.toString()}`)
       const socket = new WebSocket(websocketUrl)
+      socket.binaryType = 'arraybuffer'
       socket.addEventListener('error', () => {
         setTimeout(() => {
           establishWebsocketConnection()
diff --git a/yarn.lock b/yarn.lock
index c79fd2b18..8c2e5e79c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1702,6 +1702,48 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@cbor-extract/cbor-extract-darwin-arm64@npm:2.1.1":
+  version: 2.1.1
+  resolution: "@cbor-extract/cbor-extract-darwin-arm64@npm:2.1.1"
+  conditions: os=darwin & cpu=arm64
+  languageName: node
+  linkType: hard
+
+"@cbor-extract/cbor-extract-darwin-x64@npm:2.1.1":
+  version: 2.1.1
+  resolution: "@cbor-extract/cbor-extract-darwin-x64@npm:2.1.1"
+  conditions: os=darwin & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@cbor-extract/cbor-extract-linux-arm64@npm:2.1.1":
+  version: 2.1.1
+  resolution: "@cbor-extract/cbor-extract-linux-arm64@npm:2.1.1"
+  conditions: os=linux & cpu=arm64
+  languageName: node
+  linkType: hard
+
+"@cbor-extract/cbor-extract-linux-arm@npm:2.1.1":
+  version: 2.1.1
+  resolution: "@cbor-extract/cbor-extract-linux-arm@npm:2.1.1"
+  conditions: os=linux & cpu=arm
+  languageName: node
+  linkType: hard
+
+"@cbor-extract/cbor-extract-linux-x64@npm:2.1.1":
+  version: 2.1.1
+  resolution: "@cbor-extract/cbor-extract-linux-x64@npm:2.1.1"
+  conditions: os=linux & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@cbor-extract/cbor-extract-win32-x64@npm:2.1.1":
+  version: 2.1.1
+  resolution: "@cbor-extract/cbor-extract-win32-x64@npm:2.1.1"
+  conditions: os=win32 & cpu=x64
+  languageName: node
+  linkType: hard
+
 "@codemirror/autocomplete@npm:6.4.2":
   version: 6.4.2
   resolution: "@codemirror/autocomplete@npm:6.4.2"
@@ -2320,6 +2362,7 @@ __metadata:
     "@types/ws": 8.5.4
     "@typescript-eslint/eslint-plugin": 5.57.0
     "@typescript-eslint/parser": 5.57.0
+    cbor-x: 1.5.1
     eslint: 8.37.0
     eslint-config-prettier: 8.8.0
     eslint-plugin-jest: 27.2.1
@@ -6753,6 +6796,49 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cbor-extract@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "cbor-extract@npm:2.1.1"
+  dependencies:
+    "@cbor-extract/cbor-extract-darwin-arm64": 2.1.1
+    "@cbor-extract/cbor-extract-darwin-x64": 2.1.1
+    "@cbor-extract/cbor-extract-linux-arm": 2.1.1
+    "@cbor-extract/cbor-extract-linux-arm64": 2.1.1
+    "@cbor-extract/cbor-extract-linux-x64": 2.1.1
+    "@cbor-extract/cbor-extract-win32-x64": 2.1.1
+    node-gyp: latest
+    node-gyp-build-optional-packages: 5.0.3
+  dependenciesMeta:
+    "@cbor-extract/cbor-extract-darwin-arm64":
+      optional: true
+    "@cbor-extract/cbor-extract-darwin-x64":
+      optional: true
+    "@cbor-extract/cbor-extract-linux-arm":
+      optional: true
+    "@cbor-extract/cbor-extract-linux-arm64":
+      optional: true
+    "@cbor-extract/cbor-extract-linux-x64":
+      optional: true
+    "@cbor-extract/cbor-extract-win32-x64":
+      optional: true
+  bin:
+    download-cbor-prebuilds: bin/download-prebuilds.js
+  checksum: 283d9cdb3c716b171b5ad8666673f4ac373f975b51d9a38233d280c6f9381d66c6af4c011a561d993c4be6e427e34681bc3c5af194b9da0c9ab3401d424b7988
+  languageName: node
+  linkType: hard
+
+"cbor-x@npm:1.5.1":
+  version: 1.5.1
+  resolution: "cbor-x@npm:1.5.1"
+  dependencies:
+    cbor-extract: ^2.1.1
+  dependenciesMeta:
+    cbor-extract:
+      optional: true
+  checksum: e4ff6012194e93739a36027a2d8abbe4e98f62c003c77896bed1c22bf29782fe371480c7db37a0fc288a1a56cb0b711e48072ed6f42d51bdf96395a1ed01bcea
+  languageName: node
+  linkType: hard
+
 "chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
   version: 4.1.2
   resolution: "chalk@npm:4.1.2"
@@ -13816,6 +13902,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"node-gyp-build-optional-packages@npm:5.0.3":
+  version: 5.0.3
+  resolution: "node-gyp-build-optional-packages@npm:5.0.3"
+  bin:
+    node-gyp-build-optional-packages: bin.js
+    node-gyp-build-optional-packages-optional: optional.js
+    node-gyp-build-optional-packages-test: build-test.js
+  checksum: be3f0235925c8361e5bc1a03848f5e24815b0df8aa90bd13f1eac91cd86264bbb8b7689ca6cd083b02c8099c7b54f9fb83066c7bb77c2389dc4eceab921f084f
+  languageName: node
+  linkType: hard
+
 "node-gyp@npm:8.x":
   version: 8.4.1
   resolution: "node-gyp@npm:8.4.1"