diff --git a/.gitignore b/.gitignore
index 7c7c21c9551b5c02572f460fd69a8a70d511e72d..2966cf9287994282f90bb34f9b9e3b3b0eebd030 100644
--- a/.gitignore
+++ b/.gitignore
@@ -119,6 +119,7 @@ typings/
 
 # react / gatsby (customised)
 public/js/app.js
+public/js/queue.js
 
 # vuepress build output
 .vuepress/dist
@@ -163,4 +164,7 @@ tags
 # Ignore all local history of files
 .history
 
+# Jupyter checkpoints
+**/.ipynb_checkpoints
+
 # End of https://www.gitignore.io/api/vim,node,macos,visualstudiocode
diff --git a/package-lock.json b/package-lock.json
index 6e9d29fb5a58712a7a6764d441c07aac4809a737..3c2dd2db0e0006c37baa783f35ef75c0ba28d875 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4938,7 +4938,6 @@
         "filetransfer": "^2.0.4",
         "hark": "^1.2.0",
         "mockconsole": "0.0.1",
-        "pako": "^1.0.10",
         "rtcpeerconnection": "file:src/rtcpeerconnection",
         "socket.io-client": "^2.3.0",
         "webrtc-adapter": "^7.3.0",
diff --git a/package.json b/package.json
index 79966736740510c71a3274e2775e03d1d36ef7ff..7ba1899f33eb7f487e3977d7780476ac98985855 100644
--- a/package.json
+++ b/package.json
@@ -27,9 +27,11 @@
     "dotenv": "^8.2.0",
     "express": "^4.17.1",
     "liowebrtc": "file:src/liowebrtc",
+    "pako": "^1.0.10",
     "signalbuddy": "file:src/signalbuddy",
     "uuid": "^3.3.3",
     "webrtc-adapter": "^7.3.0",
+    "what-the-pack": "^2.0.3",
     "y-array": "^10.1.4",
     "y-map": "^10.1.3",
     "y-memory": "^8.0.9",
diff --git a/public/index.html b/public/index.html
index 9191814f9bd0b8f0ade96e56baef9c6bb21f5e74..2786830805f63ca7436be14656a952ed34ce5496 100644
--- a/public/index.html
+++ b/public/index.html
@@ -74,11 +74,29 @@
               <div id="brush-colour">
                 <h3>Brush colour</h3>
               </div>
-              &nbsp;
-              <a href="#">
+              <a href="#" style="margin-left: 1%">
                 <div id="rectangle"></div>
               </a>
             </div>
+            <div class="pen-body">
+              <div id="brush-colour">
+                <h3>Brush size</h3>
+              </div>
+              &nbsp;
+              <div class="slide-size">
+                <input
+                  type="range"
+                  min="1"
+                  max="100"
+                  value="10"
+                  class="slider"
+                  id="range"
+                />
+                <p style="text-align: center; margin: 0px">
+                  Size: <span id="value"></span>
+                </p>
+              </div>
+            </div>
           </div>
         </div>
         <div id="palette" class="properties" style="padding-top: 150px">
@@ -92,6 +110,7 @@
               viewBox="0 10 100 70"
               xmlns="http://www.w3.org/2000/svg"
               xmlns:xlink="http://www.w3.org/1999/xlink"
+              style="@media only screen and (max-width: 600px) { viewBox: -50 10 200 100 }"
             >
               <defs>
                 <path
@@ -171,7 +190,7 @@
             </svg>
             <div id="others">
               <div id="other-palette">
-                <b>Others</b>
+                <b>Other colours</b>
               </div>
               <label id="colours">
                 <input id="other-colours" type="color" value="blue" />
diff --git a/public/service-worker.js b/public/service-worker.js
index 5871487b5fe4ffd94ed873635e38ec3d665b8439..971cfcd807b0b91b549b816f7b290d37174a8c4e 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -12,6 +12,7 @@ const FILES_TO_CACHE = [
   "/styles.css",
   "/cursor.svg",
   "/js/app.js",
+  "/js/queue.js",
   "/logo.png",
   "/manifest.json",
   "/assets/fonts/martel-v4-latin/martel-v4-latin-regular.eot",
diff --git a/public/styles.css b/public/styles.css
index 96bac6340bc8844e65b9df056b1f11871be2e7f0..b8f3cc401e328a0a7e53753024afd5b171e7965c 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -361,11 +361,85 @@ button.selected {
   text-align: center;
 }
 
+.slide-size {
+  width: 90%;
+  height: 20%;
+  margin-left: 2.2%;
+}
+
+.slider {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 100%;
+  height: 25px;
+  background: #d3d3d3;
+  outline: none;
+  opacity: 0.7;
+  -webkit-transition: 0.2s;
+  transition: opacity 0.2s;
+  margin-top: 30px;
+}
+
+.slider:hover {
+  opacity: 1;
+}
+
+.slider::-webkit-slider-thumb {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 25px;
+  height: 25px;
+  background: #4f4f4f;
+  cursor: pointer;
+}
+
+.slider::-moz-range-thumb {
+  width: 25px;
+  height: 25px;
+  background: #4f4f4f;
+  cursor: pointer;
+}
+
+@media only screen and (max-width: 600px) {
+  .properties {
+    display: none;
+    position: fixed;
+    z-index: 1;
+    padding-top: 80px;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    overflow: auto;
+    background-color: rgb(0, 0, 0);
+    background-color: rgba(0, 0, 0, 0.4);
+  }
+
+  .palette-selector {
+    position: relative;
+    background-color: #fefefe;
+    margin: auto;
+    padding: 0;
+    border: 1px solid #888;
+    width: 80%;
+    height: 80%;
+    box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.1);
+    font-family: "Martel", serif;
+  }
+
+  .wheel-header {
+    padding: 2px 16px;
+    background-color: #2f2f2f;
+    color: white;
+    text-align: center;
+  }
+}
+
 #others {
   margin-top: 2%;
   display: flex;
   align-items: center;
-  padding-left: 40%;
+  justify-content: center;
   background-color: #3cbc8d;
   color: white;
   padding-top: 4px;
@@ -393,8 +467,20 @@ button.selected {
   transition-duration: 0.4s;
 }
 
-#other-colours {
-  visibility: hidden;
+@supports (-webkit-overflow-scrolling: touch) {
+  #colours {
+    visibility: hidden;
+  }
+
+  #other-colours {
+    visibility: visible;
+  }
+}
+
+@supports not (-webkit-overflow-scrolling: touch) {
+  #other-colours {
+    visibility: hidden;
+  }
 }
 
 @font-face {
diff --git a/src/app.js b/src/app.js
index d40a51b977fc8a95f864b48857a63e3f8f21f21b..d909baf29730517c6984afad5422d5f6f1540f03 100644
--- a/src/app.js
+++ b/src/app.js
@@ -55,30 +55,32 @@ const onRoomConnect = (room_) => {
       HTML.connectedPeers.innerHTML = ""
     }
 
-    insertHTMLPeerElement(id)
+    getOrInsertPeerById(id)
     updateOverallStatusIcon()
   })
 
   room.addEventListener("userLeave", ({ detail: id }) => {
-    HTML.connectedPeers.removeChild(getPeerById(id))
+    HTML.connectedPeers.removeChild(getOrInsertPeerById(id))
 
     if (HTML.connectedPeers.children.length == 0) {
       HTML.connectedPeers.innerHTML = "No peers are connected"
     }
+
+    updateOverallStatusIcon()
   })
 
   room.addEventListener("weSyncedWithPeer", ({ detail: id }) => {
-    getPeerById(id).children[1].className = "peer-status synced"
+    getOrInsertPeerById(id).children[1].className = "peer-status synced"
     updateOverallStatusIcon()
   })
 
   room.addEventListener("waitingForSyncStep", ({ detail: id }) => {
-    getPeerById(id).children[2].className = "peer-status negotiating"
+    getOrInsertPeerById(id).children[2].className = "peer-status negotiating"
     updateOverallStatusIcon()
   })
 
   room.addEventListener("peerSyncedWithUs", ({ detail: id }) => {
-    getPeerById(id).children[2].className = "peer-status synced"
+    getOrInsertPeerById(id).children[2].className = "peer-status synced"
     updateOverallStatusIcon()
   })
 
@@ -146,7 +148,7 @@ for (let i = 1; i < svg.length; i++) {
     const paletteColour = event.target.getAttribute("fill")
     HTML.rectangle.style.backgroundColor = paletteColour
     HTML.picker.value = paletteColour
-    HTML.labelColour.style.backgroundColor = paletteColour
+    HTML.labelColours.style.backgroundColor = paletteColour
     canvas.setStrokeColour(paletteColour)
     hideElement(HTML.palette)
   })
@@ -163,7 +165,33 @@ function hideElement(element) {
 HTML.picker.addEventListener("change", () => {
   const paletteColour = event.target.value
   HTML.rectangle.style.backgroundColor = paletteColour
-  HTML.labelColour.style.backgroundColor = paletteColour
+  HTML.labelColours.style.backgroundColor = paletteColour
+  canvas.setStrokeColour(paletteColour)
+})
+
+HTML.output.innerHTML = HTML.slider.value
+
+HTML.slider.oninput = function() {
+  HTML.output.innerHTML = this.value
+  canvas.setStrokeRadius(this.value / 10)
+}
+
+const x = window.matchMedia(
+  "only screen and (orientation: landscape) and (max-width: 600px)",
+)
+x.addListener(() => {
+  if (x.matches) {
+    HTML.wheel.setAttribute("viewBox", "-50 10 200 100")
+    HTML.palette.setAttribute("style", "padding-top: 50px")
+  } else {
+    HTML.wheel.setAttribute("viewBox", "0 10 100 100")
+  }
+})
+
+HTML.picker.addEventListener("change", () => {
+  const paletteColour = event.target.value
+  HTML.rectangle.style.backgroundColor = paletteColour
+  HTML.labelColours.style.backgroundColor = paletteColour
   canvas.setStrokeColour(paletteColour)
 })
 
@@ -182,7 +210,7 @@ HTML.peerButton.addEventListener("click", () => {
   HTML.peerIDElem.value = ""
 })
 
-HTML.roomConnectButton.addEventListener("click", () => {
+const onRoomJoinEnter = () => {
   const selectedRoomID = HTML.roomIDElem.value
   if (!selectedRoomID || selectedRoomID == room.name) {
     return
@@ -197,9 +225,26 @@ HTML.roomConnectButton.addEventListener("click", () => {
   HTML.connectedPeers.innerHTML = "No peers are connected"
 
   tryRoomConnect(selectedRoomID)
+}
+
+HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter)
+
+HTML.roomIDElem.addEventListener("keydown", (event) => {
+  if (event.key == "Enter") {
+    event.target.blur()
+
+    onRoomJoinEnter()
+  }
 })
 
-const insertHTMLPeerElement = (id) => {
+const getOrInsertPeerById = (id) => {
+  for (const peerElem of HTML.connectedPeers.children) {
+    const peerId = peerElem.children[0].innerHTML
+    if (peerId == id) {
+      return peerElem
+    }
+  }
+
   const peerElem = document.createElement("li")
   const peerId = document.createElement("div")
   peerId.style.display = "inline"
@@ -216,15 +261,8 @@ const insertHTMLPeerElement = (id) => {
   peerElem.appendChild(theirStatus)
 
   HTML.connectedPeers.appendChild(peerElem)
-}
 
-const getPeerById = (id) => {
-  for (const peerElem of HTML.connectedPeers.children) {
-    const peerId = peerElem.children[0].innerHTML
-    if (peerId == id) {
-      return peerElem
-    }
-  }
+  return peerElem
 }
 
 const updateOverallStatusIcon = () => {
@@ -252,7 +290,11 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => {
   if (currentTool == tools.PEN) {
     pathIDsByPointerID.set(
       e.pointerId,
-      room.addPath([...mousePos, e.pressure, canvas.getStrokeColour()]),
+      room.addPath([
+        ...mousePos,
+        canvas.getStrokeRadius(e.pressure),
+        canvas.getStrokeColour(),
+      ]),
     )
   } else if (currentTool == tools.ERASER) {
     eraseEverythingAtPosition(mousePos[0], mousePos[1], ERASER_RADIUS, room)
@@ -273,7 +315,7 @@ canvas.input.addEventListener("strokemove", ({ detail: e }) => {
   if (currentTool == tools.PEN) {
     room.extendPath(pathIDsByPointerID.get(e.pointerId), [
       ...mousePos,
-      e.pressure,
+      canvas.getStrokeRadius(e.pressure),
       canvas.getStrokeColour(),
     ])
   } else if (currentTool == tools.ERASER) {
diff --git a/src/canvas.js b/src/canvas.js
index e39bf0e2acec85e0a4c9f6609a01a18ba1730f42..2173bc72b9698e4a39e0007ac90e0f3c80fc1802 100644
--- a/src/canvas.js
+++ b/src/canvas.js
@@ -18,17 +18,18 @@ const smoothLine = line()
 
 const pathGroupElems = new Map()
 
-let stroke_colour = "blue"
-export const MIN_STROKE_RADIUS = 0.1
-export const MAX_STROKE_RADIUS = 3.9
+let strokeColour = "blue"
+let strokeRadius = 1
+export const MIN_PRESSURE = 0.1
+export const MAX_PRESSURE = 1.0
 
 const MAX_POINT_DISTANCE = 5
-const MAX_PRESSURE_DELTA = 0.05
+const MAX_RADIUS_DELTA = 0.05
 
 // Interpolate a path so that:
 // - The distance between two adjacent points is capped at MAX_POINT_DISTANCE.
 // - The pressure delta between two adjacent points is capped at
-//   MAX_PRESSURE_DELTA
+//   MAX_RADIUS_DELTA
 // If paths are too choppy, try decreasing these constants.
 const smoothPath = ([...path]) => {
   // Apply MAX_POINT_DISTANCE.
@@ -52,12 +53,12 @@ const smoothPath = ([...path]) => {
     i += newPoints.length
   }
 
-  // Apply MAX_PRESSURE_DELTA.
+  // Apply MAX_RADIUS_DELTA.
   for (let i = 1; i < path.length; i++) {
     const dx = path[i][0] - path[i - 1][0]
     const dy = path[i][1] - path[i - 1][1]
     const dw = path[i][2] - path[i - 1][2]
-    const segmentsToSplit = Math.ceil(dw / MAX_PRESSURE_DELTA)
+    const segmentsToSplit = Math.ceil(dw / MAX_RADIUS_DELTA)
     const newPoints = []
     for (let j = 1; j < segmentsToSplit; j++) {
       newPoints.push([
@@ -74,15 +75,11 @@ const smoothPath = ([...path]) => {
   return path
 }
 
-const getStrokeRadius = (pressure) => {
-  return MIN_STROKE_RADIUS + pressure * (MAX_STROKE_RADIUS - MIN_STROKE_RADIUS)
-}
-
 export const input = new EventTarget()
 
 const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName)
 
-function erasurePoints(point0, point1, [start, fin]) {
+const erasurePoints = (point0, point1, [start, fin]) => {
   if (start >= fin) return
   if (start <= 0) start = 0
   if (fin >= 1) fin = 1
@@ -100,14 +97,14 @@ function erasurePoints(point0, point1, [start, fin]) {
   ])
 }
 
-function ensurePathGroupElem(id) {
+const ensurePathGroupElem = (id) => {
   let groupElem = pathGroupElems.get(id)
 
   if (groupElem == null) {
     groupElem = createSvgElem("g")
 
-    groupElem.setAttribute("stroke", stroke_colour)
     groupElem.setAttribute("fill", "none")
+    groupElem.setAttribute("stroke-linecap", "round")
     groupElem.setAttribute("pointer-events", "none")
 
     HTML.canvas.appendChild(groupElem)
@@ -118,33 +115,34 @@ function ensurePathGroupElem(id) {
   return groupElem
 }
 
-function renderPoint(point, colour) {
+const renderPoint = (point) => {
   const circleElem = createSvgElem("circle")
 
   circleElem.setAttribute("stroke", "none")
-  circleElem.setAttribute("fill", colour)
+  circleElem.setAttribute("fill", point[3])
   circleElem.setAttribute("cx", point[0])
   circleElem.setAttribute("cy", point[1])
-  circleElem.setAttribute("r", getStrokeRadius(point[2]))
+  circleElem.setAttribute("r", point[2])
 
   return circleElem
 }
 
-function renderSubpath(subpath) {
+const renderSubpath = (subpath) => {
   if (subpath.length == 1) {
     return renderPoint(subpath[0])
   }
   const pathElem = createSvgElem("path")
-  pathElem.setAttribute("stroke-width", getStrokeRadius(subpath[0][2]) * 2)
+  pathElem.setAttribute("stroke", subpath[0][3])
+  pathElem.setAttribute("stroke-width", subpath[0][2] * 2)
   pathElem.setAttribute("d", smoothLine(subpath))
   return pathElem
 }
 
-function isValidPoint(point) {
+const isValidPoint = (point) => {
   return point != null && point[0] != null
 }
 
-function getEraseIntervalsForPointInPath(intervals, pathID, pointID) {
+const getEraseIntervalsForPointInPath = (intervals, pathID, pointID) => {
   if (!intervals) return undefined
   const eraseIntervalsForPath = intervals[pathID]
   if (!eraseIntervalsForPath) return undefined
@@ -153,7 +151,7 @@ function getEraseIntervalsForPointInPath(intervals, pathID, pointID) {
 
 const POINT_ERASE_LIMIT = 0.0001
 
-function pointWasErased(eraseIntervals) {
+const pointWasErased = (eraseIntervals) => {
   return (
     eraseIntervals.length &&
     eraseIntervals[0] &&
@@ -161,7 +159,7 @@ function pointWasErased(eraseIntervals) {
   )
 }
 
-function needToDrawLastPoint(points, pathID, eraseIntervals) {
+const needToDrawLastPoint = (points, pathID, eraseIntervals) => {
   if (points.length < 2) return true
   const penultimatePointIndex = points.length - 2
   const penPointEraseIntervals = getEraseIntervalsForPointInPath(
@@ -182,10 +180,11 @@ function needToDrawLastPoint(points, pathID, eraseIntervals) {
   return true
 }
 
-function applyErasureIntervals(pathID, points, erasureIntervals) {
+const applyErasureIntervals = (pathID, points, erasureIntervals) => {
   if (points.length == 0) {
     return []
   }
+
   const subpaths = []
 
   let subpath = []
@@ -271,7 +270,7 @@ export const clear = () => {
 }
 
 // Necessary since buttons property is non standard on iOS versions < 13.2
-function isValidPointerEvent(e) {
+const isValidPointerEvent = (e) => {
   return e.buttons & 1 || e.pointerType === "touch"
 }
 
@@ -289,10 +288,22 @@ canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
 canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
 canvas.addEventListener("touchmove", (e) => e.preventDefault())
 
-export function setStrokeColour(colour) {
-  stroke_colour = colour
+export const setStrokeColour = (colour) => {
+  strokeColour = colour
+}
+
+export const getStrokeColour = () => {
+  return strokeColour
+}
+
+export const setStrokeRadius = (radius) => {
+  strokeRadius = radius
+}
+
+const calculateStrokeRadius = (pressure, radius) => {
+  return radius * (MIN_PRESSURE + pressure * (MAX_PRESSURE - MIN_PRESSURE))
 }
 
-export function getStrokeColour() {
-  return stroke_colour
+export const getStrokeRadius = (pressure) => {
+  return calculateStrokeRadius(pressure, strokeRadius)
 }
diff --git a/src/elements.js b/src/elements.js
index 9bab250a95996d204eed8bc8bc4ec8db6511d3e7..cf472b7cb559dd0b80284f3f8b1e6ddac9735a30 100644
--- a/src/elements.js
+++ b/src/elements.js
@@ -26,4 +26,6 @@ export const palette = document.getElementById("palette")
 export const rectangle = document.getElementById("rectangle")
 export const wheel = document.getElementById("wheel")
 export const picker = document.getElementById("other-colours")
-export const labelColour = document.getElementById("colours")
+export const slider = document.getElementById("range")
+export const output = document.getElementById("value")
+export const labelColours = document.getElementById("colours")
diff --git a/src/liowebrtc b/src/liowebrtc
index 9b066b28a56a902b7ba0fe1532508bf28f269604..ce4a2ebe160804ed84f7b6fc3bd10c91e766bdcd 160000
--- a/src/liowebrtc
+++ b/src/liowebrtc
@@ -1 +1 @@
-Subproject commit 9b066b28a56a902b7ba0fe1532508bf28f269604
+Subproject commit ce4a2ebe160804ed84f7b6fc3bd10c91e766bdcd
diff --git a/src/queue.js b/src/queue.js
new file mode 100644
index 0000000000000000000000000000000000000000..6aeed4438621d58337dab675e90ae85bef79c7a5
--- /dev/null
+++ b/src/queue.js
@@ -0,0 +1,93 @@
+"use strict"
+
+import MessagePack from "what-the-pack"
+import pako from "pako"
+import uuidv4 from "uuid/v4"
+
+const MESSAGE_BUFFER_SIZE = 2 ** 24 // 16MB
+const MESSAGE_SLICE_SIZE = 2 ** 10 // 1KB
+
+const { encode, decode } = MessagePack.initialize(MESSAGE_BUFFER_SIZE)
+
+const buffer = {}
+
+self.onmessage = (event) => {
+  if (!event || !event.data) {
+    return
+  }
+
+  if (event.data.method == "send" || event.data.method == "broadcast") {
+    let message = event.data.message
+    const compressed = typeof message == "object"
+    const uuid = uuidv4()
+
+    message = encode(message)
+
+    if (compressed) {
+      message = pako.deflate(message)
+    }
+
+    for (
+      let offset = 0;
+      offset < message.length;
+      offset += MESSAGE_SLICE_SIZE
+    ) {
+      event.data.message = {
+        uuid,
+        message: message.subarray(offset, offset + MESSAGE_SLICE_SIZE),
+        slice: offset / MESSAGE_SLICE_SIZE,
+        length: Math.ceil(message.length / MESSAGE_SLICE_SIZE),
+        compressed,
+      }
+
+      self.postMessage(event.data)
+    }
+  } else if (event.data.method == "received") {
+    let message = event.data.message.message
+
+    if (event.data.message.length > 1) {
+      let messages = buffer[event.data.peer.id]
+      if (!messages) {
+        messages = {}
+        buffer[event.data.peer.id] = messages
+      }
+
+      let slices = messages[event.data.message.uuid]
+      if (!slices) {
+        slices = []
+        messages[event.data.message.uuid] = slices
+      }
+
+      slices.push(event.data.message)
+
+      if (slices.length < slices[slices.length - 1].length) {
+        return
+      }
+
+      message = new Uint8Array(
+        slices.reduce((acc, s) => acc + s.message.length, 0),
+      )
+
+      slices.sort((a, b) => a.slice - b.slice)
+
+      let offset = 0
+
+      for (const slice of slices) {
+        message.set(slice.message, offset)
+        offset += slice.message.length
+      }
+
+      delete messages[event.data.message.uuid]
+    }
+
+    if (event.data.message.compressed) {
+      message = pako.inflate(message)
+    }
+
+    message = decode(MessagePack.Buffer.from(message))
+
+    event.data.message = message
+
+    self.postMessage(event.data)
+  }
+}
diff --git a/src/room.js b/src/room.js
index 441e635e35ed10a8529aa5e306321c49530855c3..8f73fcf53732e9f32a8aee911791f8b109e6ee64 100644
--- a/src/room.js
+++ b/src/room.js
@@ -110,9 +110,23 @@ class Room extends EventTarget {
         name: "webrtc",
         url: "/",
         room: this.name,
+        handshake: {
+          initial: 100,
+          interval: 500,
+        },
+        heartbeat: {
+          interval: 500,
+          minimum: 1000,
+          timeout: 10000,
+        },
         onUserEvent: (event) => {
-          if (event.action == "userID") {
-            const { id } = event
+          if (event.action == "userConnection") {
+            const { quality } = event
+            this.dispatchEvent(
+              new CustomEvent("userConnection", { detail: quality }),
+            )
+          } else if (event.action == "userID") {
+            const { user: id } = event
             this.ownID = id
             this.dispatchEvent(new CustomEvent("allocateOwnID", { detail: id }))
           } else if (event.action == "userJoined") {
diff --git a/src/y-webrtc/index.js b/src/y-webrtc/index.js
index 2324e71cfb00b54c5b42c9bc83b7b04679eddca2..ba19f1032078541c92eb502ba87b81012f637b11 100644
--- a/src/y-webrtc/index.js
+++ b/src/y-webrtc/index.js
@@ -14,6 +14,73 @@ function extend(Y) {
       super(y, options)
       this.webrtcOptions = options
 
+      this.webrtcOptions.handshake = this.webrtcOptions.handshake || {}
+      this.webrtcOptions.handshake.initial =
+        this.webrtcOptions.handshake.initial || 100
+      this.webrtcOptions.handshake.interval =
+        this.webrtcOptions.handshake.interval || 500
+
+      this.webrtcOptions.heartbeat = this.webrtcOptions.heartbeat || {}
+      this.webrtcOptions.heartbeat.interval =
+        this.webrtcOptions.heartbeat.interval || 500
+      this.webrtcOptions.heartbeat.minimum =
+        this.webrtcOptions.heartbeat.minimum || 1000
+      this.webrtcOptions.heartbeat.timeout =
+        this.webrtcOptions.heartbeat.timeout || 10000
+
+      this.queue = new Worker("js/queue.js")
+      this.queue.onmessage = (event) => {
+        const method = event.data.method
+
+        if (method == "send") {
+          const { uid, channel, message } = event.data
+
+          // y-js db transactions can send messages after a peer has disconnected
+          if (
+            (channel == "y-js" && !this.peers.has(uid)) ||
+            !this.webrtc.getPeerById(uid)
+          ) {
+            return
+          }
+
+          this.webrtc.whisper(this.webrtc.getPeerById(uid), channel, message)
+        } else if (method == "broadcast") {
+          const { channel, message } = event.data
+
+          return this.webrtc.shout(channel, message)
+        } else if (method == "received") {
+          const { type, message, peer } = event.data
+
+          if (type === "tw-ml") {
+            // Handshakes can only be sent and received directly
+            if (message === "tw") {
+              // Response message in the handshake
+              this.queue.postMessage({
+                method: "send",
+                uid: peer.id,
+                channel: "tw-ml",
+                message: "ml",
+              })
+            } else if (message == "ml") {
+              // Handshake completed
+              this.checkAndInsertPeer(peer.id)
+            }
+          } else {
+            this.checkAndInsertPeer(peer.id)
+
+            if (type === "y-js") {
+              this.checkAndInsertPeer(peer.id)
+
+              if (message.type === "sync done") {
+                this.raiseUserEvent("peerSyncedWithUs", { user: peer.id })
+              }
+
+              this.receiveMessage(peer.id, message)
+            }
+          }
+        }
+      }
+
       if (options.onUserEvent) {
         this.onUserEvent(options.onUserEvent)
       }
@@ -28,13 +95,13 @@ function extend(Y) {
       this.webrtc = new LioWebRTC({
         url: this.webrtcOptions.url,
         dataOnly: true,
-        /*network: {
+        constraints: {
           minPeers: 4,
           maxPeers: 8,
-        },*/
+        },
       })
 
-      this.peers = new Set()
+      this.peers = new Map()
 
       this.webrtc.on("ready", () => {
         this.webrtc.joinRoom(this.webrtcOptions.room)
@@ -45,9 +112,13 @@ function extend(Y) {
       })
 
       this.webrtc.on("leftRoom", () => {
-        console.log("LEFT ROOM")
+        console.log("TODO: LEFT ROOM")
       })
 
+      this.webrtc.on("channelError", (a, b, c, d) =>
+        console.log("TODO: CHANNEL ERROR", a, b, c, d),
+      )
+
       this.webrtc.on("channelOpen", (dataChannel, peer) => {
         this.checkAndEnsureUser()
 
@@ -63,40 +134,36 @@ function extend(Y) {
             return
           }
 
-          console.log("ping", peer.id)
-
           // Initial message in the handshake
-          this.webrtc.whisper(peer, "tw-ml", "tw")
-
-          setTimeout(handshake.bind(this, peer), 500)
+          this.queue.postMessage({
+            method: "send",
+            uid: peer.id,
+            channel: "tw-ml",
+            message: "tw",
+          })
+
+          setTimeout(
+            handshake.bind(this, peer),
+            this.webrtcOptions.handshake.interval,
+          )
         }
 
-        setTimeout(handshake.bind(this, peer), 100)
+        setTimeout(
+          handshake.bind(this, peer),
+          this.webrtcOptions.handshake.initial,
+        )
       })
 
       this.webrtc.on("receivedPeerData", (type, message, peer) => {
         this.checkAndEnsureUser()
 
-        if (message.type !== "update") {
-          console.log("receivedData", peer.id, message)
-        }
-
-        if (message.type === "sync done") {
-          this.raiseUserEvent("peerSyncedWithUs", peer.id)
-        }
-
-        if (type === "y-js") {
-          this.checkAndInsertPeer(peer.id)
-          this.receiveMessage(peer.id, message)
-        } else if (type === "tw-ml") {
-          if (message === "tw") {
-            // Response message in the handshake
-            this.webrtc.whisper(peer, "tw-ml", "ml")
-          } else if (message == "ml") {
-            // Handshake completed
-            this.checkAndInsertPeer(peer.id)
-          }
-        }
+        // Message could have been forwarded but yjs only needs to know about directly connected peers
+        this.queue.postMessage({
+          method: "received",
+          type,
+          message,
+          peer: { id: peer.forwardedBy ? peer.forwardedBy.id : peer.id },
+        })
       })
 
       this.webrtc.on("channelClose", (dataChannel, peer) => {
@@ -113,7 +180,7 @@ function extend(Y) {
         return
       }
 
-      this.raiseUserEvent("userID", id)
+      this.raiseUserEvent("userID", { user: id })
 
       this.setUserId(id)
     }
@@ -124,13 +191,91 @@ function extend(Y) {
         return
       }
 
-      this.peers.add(uid)
+      const health = {
+        lastStatsResolved: true,
+        lastReceivedBytes: 0,
+        lastReceivedTimestamp: Date.now(),
+      }
+      health.cb = setInterval(
+        this.heartbeat.bind(this, this.webrtc.getPeerById(uid), health),
+        this.webrtcOptions.heartbeat.interval,
+      )
 
-      console.log("createdPeer", uid)
+      this.peers.set(uid, health)
 
       this.userJoined(uid, "master")
     }
 
+    heartbeat(peer, health) {
+      const _peer = this.webrtc.getPeerById(peer.id)
+
+      if (!_peer || _peer !== peer || !this.peers.has(peer.id)) {
+        clearInterval(health.cb)
+
+        return
+      }
+
+      if (!health.lastStatsResolved) {
+        return peer.end(true)
+      }
+      health.lastStatsResolved = false
+
+      const self = this
+
+      peer.getStats(null).then((stats) => {
+        health.lastStatsResolved = true
+
+        let disconnect = true
+
+        stats.forEach((report) => {
+          if (
+            report.type == "candidate-pair" &&
+            report.bytesSent > 0 &&
+            report.bytesReceived > 0 &&
+            report.writable
+          ) {
+            const timeSinceLastReceived =
+              Date.now() - health.lastReceivedTimestamp
+
+            if (report.bytesReceived != health.lastReceivedBytes) {
+              health.lastReceivedBytes = report.bytesReceived
+              health.lastReceivedTimestamp = Date.now()
+            } else if (
+              timeSinceLastReceived > self.webrtcOptions.heartbeat.timeout
+            ) {
+              return
+            } else if (
+              timeSinceLastReceived > self.webrtcOptions.heartbeat.interval
+            ) {
+              self.queue.postMessage({
+                method: "send",
+                uid: peer.id,
+                channel: "heartbeat",
+              })
+            }
+
+            this.raiseUserEvent("userConnection", {
+              quality:
+                1.0 -
+                (self.webrtcOptions.heartbeat.timeout -
+                  Math.max(
+                    timeSinceLastReceived,
+                    self.webrtcOptions.heartbeat.minimum,
+                  )) /
+                  (self.webrtcOptions.heartbeat.timeout -
+                    self.webrtcOptions.heartbeat.minimum),
+            })
+
+            disconnect = false
+          }
+        })
+
+        if (disconnect) {
+          peer.end(true)
+        }
+      })
+    }
+
     // Ensure that y-js knows that the peer has left
     checkAndRemovePeer(uid) {
       if (!this.peers.has(uid)) {
@@ -139,8 +284,6 @@ function extend(Y) {
 
       this.peers.delete(uid)
 
-      console.log("removedPeer", uid)
-
       this.userLeft(uid)
     }
 
@@ -149,6 +292,8 @@ function extend(Y) {
     }
 
     disconnect() {
+      this.queue.terminate()
+
       this.webrtc.quit()
 
       super.disconnect()
@@ -160,33 +305,26 @@ function extend(Y) {
       super.reconnect()
     }
 
-    raiseUserEvent(action, user_id) {
+    raiseUserEvent(action, data) {
+      const event = Object.assign({ action }, data)
+
       for (const f of this.userEventListeners) {
-        f({ action: action, user: user_id })
+        f(event)
       }
     }
 
     send(uid, message) {
-      // y-js db transactions can send messages after a peer has disconnected
-      if (!this.peers.has(uid) || !this.webrtc.getPeerById(uid)) {
-        return
-      }
-
-      console.log("send", uid, message)
-
       if (message.type === "sync step 1") {
-        this.raiseUserEvent("waitingForSyncStep", uid)
+        this.raiseUserEvent("waitingForSyncStep", { user: uid })
       } else if (message.type === "sync done") {
-        this.raiseUserEvent("weSyncedWithPeer", uid)
+        this.raiseUserEvent("weSyncedWithPeer", { user: uid })
       }
 
-      this.webrtc.whisper(this.webrtc.getPeerById(uid), "y-js", message)
+      this.queue.postMessage({ method: "send", channel: "y-js", uid, message })
     }
 
     broadcast(message) {
-      if (message.type !== "update") console.log("broadcast", message)
-
-      this.webrtc.shout("y-js", message)
+      this.queue.postMessage({ method: "broadcast", channel: "y-js", message })
     }
 
     isDisconnected() {
diff --git a/src/yjs b/src/yjs
index ba068ea6a62d6a124047fd3dd0075adecd5d149a..c2a07807dfaa1e5e6b667d83aa4a7d348c759c0f 160000
--- a/src/yjs
+++ b/src/yjs
@@ -1 +1 @@
-Subproject commit ba068ea6a62d6a124047fd3dd0075adecd5d149a
+Subproject commit c2a07807dfaa1e5e6b667d83aa4a7d348c759c0f
diff --git a/webpack.common.js b/webpack.common.js
index 1c8c6567d4a91292e26fcb3ec441332eaf9dad8e..bcb271eee9f39438cb631d914c77bb6a9f118728 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -3,9 +3,12 @@ const webpack = require("webpack")
 
 module.exports = {
   mode: "development",
-  entry: "./src/app.js",
+  entry: {
+    app: "./src/app.js",
+    queue: "./src/queue.js",
+  },
   output: {
-    filename: "app.js",
+    filename: "[name].js",
     path: path.resolve(__dirname, "public/js"),
   },
   plugins: [