diff --git a/packages/sse/README.md b/packages/sse/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c6172bce3bac85add0a8187bbc437147527c9126
--- /dev/null
+++ b/packages/sse/README.md
@@ -0,0 +1,31 @@
+# pubsweet-sse
+
+Broadcast server-sent events to connected Express clients.
+
+## Install
+
+```
+npm install pubsweet-sse
+```
+
+or
+
+```
+yarn install pubsweet-sse
+```
+
+## Example usage in Express
+
+```js
+const sse = require('pubsweet-sse')
+
+// set the /updates route for authenticated clients to connect using EventSource
+app.get('/updates', passport.authenticate('bearer'), sse.connect)
+
+// broadcast data to all connected clients
+sse.send({ foo: 'bar' })
+```
+
+## Alternatives
+
+This module was originally adapted from [express-sse](https://github.com/dpskvn/express-sse)
diff --git a/packages/sse/lib/SSE.js b/packages/sse/lib/SSE.js
new file mode 100644
index 0000000000000000000000000000000000000000..f222777051026cabdd9a2a128f14bf3de74ad5da
--- /dev/null
+++ b/packages/sse/lib/SSE.js
@@ -0,0 +1,72 @@
+const EventEmitter = require('events').EventEmitter
+
+class SSE extends EventEmitter {
+  constructor () {
+    super()
+
+    this.connect = this.connect.bind(this)
+    this.messageId = 0
+    this.pulse()
+  }
+
+  connect (req, res) {
+    // if (req.header('Accept').indexOf('text/event-stream') === -1) {
+      // TODO: throw exception?
+    // }
+
+    req.socket.setTimeout(Number.MAX_SAFE_INTEGER)
+    req.socket.setNoDelay(true)
+    req.socket.setKeepAlive(true)
+
+    res.statusCode = 200
+
+    res.set({
+      'Content-Type': 'text/event-stream',
+      'Cache-Control': 'no-cache',
+      'Connection': 'keep-alive',
+      'X-Accel-Buffering': 'no' // prevent buffering by nginx
+    })
+
+    this.setMaxListeners(this.getMaxListeners() + 1)
+
+    const write = (type, data) => {
+      res.write(type + ': ' + data)
+      res.write('\n')
+    }
+
+    const dataListener = data => {
+      write('id', this.messageId++)
+
+      if (data.event) {
+        write('event', data.event)
+      }
+
+      write('data', JSON.stringify(data.data))
+
+      res.write('\n')
+    }
+
+    // TODO: store all updates, use Last-Event-ID to send missed messages on reconnect
+
+    this.on('data', dataListener)
+
+    req.on('close', () => {
+      this.removeListener('data', dataListener)
+      this.setMaxListeners(this.getMaxListeners() - 1)
+    })
+  }
+
+  pulse () {
+    const pulseInterval = setInterval(() => {
+      this.emit('data', {event: 'pulse', data: Date.now()})
+    }, 10000)
+
+    pulseInterval.unref()
+  }
+
+  send (data, event) {
+    this.emit('data', {data, event})
+  }
+}
+
+module.exports = new SSE()
diff --git a/packages/sse/package.json b/packages/sse/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..7239c3371809ca49a6fc3b6c94cea450e924da0f
--- /dev/null
+++ b/packages/sse/package.json
@@ -0,0 +1,28 @@
+{
+  "name": "pubsweet-sse",
+  "description": "Broadcast server-sent events to connected Express clients",
+  "version": "0.1.3",
+  "homepage": "https://gitlab.coko.foundation/pubsweet/pubsweet-sse",
+  "repository": {
+    "type": "git",
+    "url": "git+https://gitlab.coko.foundation/pubsweet/pubsweet-sse.git"
+  },
+  "author": "Collaborative Knowledge Foundation",
+  "license": "MIT",
+  "main": "lib/SSE.js",
+  "files": [
+    "lib"
+  ],
+  "engines": {
+    "node": ">= 6"
+  },
+  "devDependencies": {
+    "standard": "^10.0.2"
+  },
+  "eslintConfig": {
+    "extends": "standard",
+    "parserOptions": {
+      "ecmaVersion": 6
+    }
+  }
+}