diff --git a/js/src/comm.ts b/js/src/comm.ts
index 3c1c7b0..28a03d8 100644
--- a/js/src/comm.ts
+++ b/js/src/comm.ts
@@ -1,3 +1,4 @@
+import { encode } from "base64-arraybuffer";
 import { Throttler } from "./utils";
 
 // This class is a striped down version of Comm from @jupyter-widgets/base
@@ -29,11 +30,17 @@ export class ShinyComm {
     metadata?: any,
     buffers?: ArrayBuffer[] | ArrayBufferView[]
   ): string {
+
+    // Encode buffers as base64 before stringifying the message
+    const buffers_64 = (buffers || []).map((b: ArrayBuffer | ArrayBufferView) => {
+      const buffer = b instanceof ArrayBuffer ? b : b.buffer;
+      return encode(buffer);
+    });
+
     const msg = {
       content: {comm_id: this.comm_id, data: data},
       metadata: metadata,
-      // TODO: need to _encode_ any buffers into base64 (JSON.stringify just drops them)
-      buffers: buffers || [],
+      buffers: buffers_64,
       // this doesn't seem relevant to the widget?
       header: {}
     };
diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py
index 034a4ac..4fc9e8c 100644
--- a/shinywidgets/_shinywidgets.py
+++ b/shinywidgets/_shinywidgets.py
@@ -125,6 +125,7 @@ def _():
         msg = json.loads(msg_txt)
         comm_id = msg["content"]["comm_id"]
         comm: ShinyComm = COMM_MANAGER.comms[comm_id]
+        # TODO: seems we probably need to transform (base64 encoded) buffers back into bytes/memoryviews?
         comm.handle_msg(msg)
 
     def _restore_state():
@@ -147,6 +148,7 @@ def _restore_state():
 # Reactivity
 # --------------------------------------
 
+
 def reactive_read(widget: Widget, names: Union[str, Sequence[str]]) -> Any:
     """
     Reactively read a widget trait
diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js
index fc2289a..682599a 100644
--- a/shinywidgets/static/output.js
+++ b/shinywidgets/static/output.js
@@ -26,7 +26,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac
   \*********************/
 /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
 
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"ShinyComm\": () => (/* binding */ ShinyComm)\n/* harmony export */ });\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ \"./src/utils.ts\");\n\n// This class is a striped down version of Comm from @jupyter-widgets/base\n// https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/base/src/services-shim.ts#L192-L335\n// Note that the Kernel.IComm implementation is located here\n// https://github.com/jupyterlab/jupyterlab/blob/master/packages/services/src/kernel/comm.ts\nclass ShinyComm {\n    constructor(model_id) {\n        this.comm_id = model_id;\n        // TODO: make this configurable (see comments in send() below)?\n        this.throttler = new _utils__WEBPACK_IMPORTED_MODULE_0__.Throttler(100);\n    }\n    // This might not be needed\n    get target_name() {\n        return \"jupyter.widgets\";\n    }\n    send(data, callbacks, metadata, buffers) {\n        const msg = {\n            content: { comm_id: this.comm_id, data: data },\n            metadata: metadata,\n            buffers: buffers || [],\n            // this doesn't seem relevant to the widget?\n            header: {}\n        };\n        const msg_txt = JSON.stringify(msg);\n        // Since ipyleaflet can send mousemove events very quickly when hovering over the map,\n        // we throttle them to ensure that the server doesn't get overwhelmed. Said events\n        // generate a payload that looks like this:\n        // {\"method\": \"custom\", \"content\": {\"event\": \"interaction\", \"type\": \"mousemove\", \"coordinates\": [-17.76259815404015, 12.096729340756617]}}\n        //\n        // TODO: This is definitely not ideal. It would be better to have a way to specify/\n        // customize throttle rates instead of having such a targetted fix for ipyleaflet.\n        const is_mousemove = data.method === \"custom\" &&\n            data.content.event === \"interaction\" &&\n            data.content.type === \"mousemove\";\n        if (is_mousemove) {\n            this.throttler.throttle(() => {\n                Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n            });\n        }\n        else {\n            this.throttler.flush();\n            Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n        }\n        // When client-side changes happen to the WidgetModel, this send method\n        // won't get called for _every_  change (just the first one). The\n        // expectation is that this method will eventually end up calling itself\n        // (via callbacks) when the server is ready (i.e., idle) to receive more\n        // updates. To make sense of this, see\n        // https://github.com/jupyter-widgets/ipywidgets/blob/88cec8b/packages/base/src/widget.ts#L550-L557\n        if (callbacks && callbacks.iopub && callbacks.iopub.status) {\n            setTimeout(() => {\n                // TODO-future: it doesn't seem quite right to report that shiny is always idle.\n                // Maybe listen to the shiny-busy flag?\n                // const state = document.querySelector(\"html\").classList.contains(\"shiny-busy\") ? \"busy\" : \"idle\";\n                const msg = { content: { execution_state: \"idle\" } };\n                callbacks.iopub.status(msg);\n            }, 0);\n        }\n        return this.comm_id;\n    }\n    open(data, callbacks, metadata, buffers) {\n        // I don't think we need to do anything here?\n        return this.comm_id;\n    }\n    close(data, callbacks, metadata, buffers) {\n        // I don't think we need to do anything here?\n        return this.comm_id;\n    }\n    on_msg(callback) {\n        this._msg_callback = callback.bind(this);\n    }\n    on_close(callback) {\n        this._close_callback = callback.bind(this);\n    }\n    handle_msg(msg) {\n        if (this._msg_callback)\n            this._msg_callback(msg);\n    }\n    handle_close(msg) {\n        if (this._close_callback)\n            this._close_callback(msg);\n    }\n}\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/comm.ts?");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"ShinyComm\": () => (/* binding */ ShinyComm)\n/* harmony export */ });\n/* harmony import */ var base64_arraybuffer__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! base64-arraybuffer */ \"./node_modules/base64-arraybuffer/dist/base64-arraybuffer.es5.js\");\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils */ \"./src/utils.ts\");\n\n\n// This class is a striped down version of Comm from @jupyter-widgets/base\n// https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/base/src/services-shim.ts#L192-L335\n// Note that the Kernel.IComm implementation is located here\n// https://github.com/jupyterlab/jupyterlab/blob/master/packages/services/src/kernel/comm.ts\nclass ShinyComm {\n    constructor(model_id) {\n        this.comm_id = model_id;\n        // TODO: make this configurable (see comments in send() below)?\n        this.throttler = new _utils__WEBPACK_IMPORTED_MODULE_1__.Throttler(100);\n    }\n    // This might not be needed\n    get target_name() {\n        return \"jupyter.widgets\";\n    }\n    send(data, callbacks, metadata, buffers) {\n        // Encode buffers as base64 before stringifying the message\n        const buffers_64 = (buffers || []).map((b) => {\n            const buffer = b instanceof ArrayBuffer ? b : b.buffer;\n            return (0,base64_arraybuffer__WEBPACK_IMPORTED_MODULE_0__.encode)(buffer);\n        });\n        const msg = {\n            content: { comm_id: this.comm_id, data: data },\n            metadata: metadata,\n            buffers: buffers_64,\n            // this doesn't seem relevant to the widget?\n            header: {}\n        };\n        const msg_txt = JSON.stringify(msg);\n        // Since ipyleaflet can send mousemove events very quickly when hovering over the map,\n        // we throttle them to ensure that the server doesn't get overwhelmed. Said events\n        // generate a payload that looks like this:\n        // {\"method\": \"custom\", \"content\": {\"event\": \"interaction\", \"type\": \"mousemove\", \"coordinates\": [-17.76259815404015, 12.096729340756617]}}\n        //\n        // TODO: This is definitely not ideal. It would be better to have a way to specify/\n        // customize throttle rates instead of having such a targetted fix for ipyleaflet.\n        const is_mousemove = data.method === \"custom\" &&\n            data.content.event === \"interaction\" &&\n            data.content.type === \"mousemove\";\n        if (is_mousemove) {\n            this.throttler.throttle(() => {\n                Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n            });\n        }\n        else {\n            this.throttler.flush();\n            Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n        }\n        // When client-side changes happen to the WidgetModel, this send method\n        // won't get called for _every_  change (just the first one). The\n        // expectation is that this method will eventually end up calling itself\n        // (via callbacks) when the server is ready (i.e., idle) to receive more\n        // updates. To make sense of this, see\n        // https://github.com/jupyter-widgets/ipywidgets/blob/88cec8b/packages/base/src/widget.ts#L550-L557\n        if (callbacks && callbacks.iopub && callbacks.iopub.status) {\n            setTimeout(() => {\n                // TODO-future: it doesn't seem quite right to report that shiny is always idle.\n                // Maybe listen to the shiny-busy flag?\n                // const state = document.querySelector(\"html\").classList.contains(\"shiny-busy\") ? \"busy\" : \"idle\";\n                const msg = { content: { execution_state: \"idle\" } };\n                callbacks.iopub.status(msg);\n            }, 0);\n        }\n        return this.comm_id;\n    }\n    open(data, callbacks, metadata, buffers) {\n        // I don't think we need to do anything here?\n        return this.comm_id;\n    }\n    close(data, callbacks, metadata, buffers) {\n        // I don't think we need to do anything here?\n        return this.comm_id;\n    }\n    on_msg(callback) {\n        this._msg_callback = callback.bind(this);\n    }\n    on_close(callback) {\n        this._close_callback = callback.bind(this);\n    }\n    handle_msg(msg) {\n        if (this._msg_callback)\n            this._msg_callback(msg);\n    }\n    handle_close(msg) {\n        if (this._close_callback)\n            this._close_callback(msg);\n    }\n}\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/comm.ts?");
 
 /***/ }),
 
@@ -46,7 +46,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _jup
   \**********************/
 /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
 
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"Throttler\": () => (/* binding */ Throttler),\n/* harmony export */   \"jsonParse\": () => (/* binding */ jsonParse)\n/* harmony export */ });\n/* harmony import */ var base64_arraybuffer__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! base64-arraybuffer */ \"./node_modules/base64-arraybuffer/dist/base64-arraybuffer.es5.js\");\n\n// On the server, we're using jupyter_client.session.json_packer to serialize messages,\n// and it encodes binary data (i.e., buffers) as base64, so decode it before passing it\n// along to the comm logic\nfunction jsonParse(x) {\n    const msg = JSON.parse(x);\n    msg.buffers = msg.buffers.map((b) => (0,base64_arraybuffer__WEBPACK_IMPORTED_MODULE_0__.decode)(b));\n    return msg;\n}\nclass Throttler {\n    constructor(wait = 100) {\n        if (wait < 0)\n            throw new Error(\"wait must be a positive number\");\n        this.wait = wait;\n        this._reset();\n    }\n    // Try to execute the function immediately, if it is not waiting\n    // If it is waiting, update the function to be called\n    throttle(fn) {\n        if (fn.length > 0)\n            throw new Error(\"fn must not take any arguments\");\n        if (this.isWaiting) {\n            // If the timeout is currently waiting, update the func to be called\n            this.fnToCall = fn;\n        }\n        else {\n            // If there is nothing waiting, call it immediately\n            // and start the throttling\n            fn();\n            this._setTimeout();\n        }\n    }\n    // Execute the function immediately and reset the timeout\n    // This is useful when the timeout is waiting and we want to\n    // execute the function immediately to not have events be out\n    // of order\n    flush() {\n        if (this.fnToCall)\n            this.fnToCall();\n        this._reset();\n    }\n    _setTimeout() {\n        this.timeoutId = setTimeout(() => {\n            if (this.fnToCall) {\n                this.fnToCall();\n                this.fnToCall = null;\n                // Restart the timeout as we just called the function\n                // This call is the key step of Throttler\n                this._setTimeout();\n            }\n            else {\n                this._reset();\n            }\n        }, this.wait);\n    }\n    _reset() {\n        this.fnToCall = null;\n        clearTimeout(this.timeoutId);\n        this.timeoutId = null;\n    }\n    get isWaiting() {\n        return this.timeoutId !== null;\n    }\n}\n\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/utils.ts?");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"Throttler\": () => (/* binding */ Throttler),\n/* harmony export */   \"jsonParse\": () => (/* binding */ jsonParse)\n/* harmony export */ });\n/* harmony import */ var base64_arraybuffer__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! base64-arraybuffer */ \"./node_modules/base64-arraybuffer/dist/base64-arraybuffer.es5.js\");\n\n// On the server, we're using jupyter_client.session.json_packer to serialize messages,\n// and it encodes binary data (i.e., buffers) as base64, so decode it before passing it\n// along to the comm logic\nfunction jsonParse(x) {\n    const msg = JSON.parse(x);\n    msg.buffers = msg.buffers.map((base64) => new DataView((0,base64_arraybuffer__WEBPACK_IMPORTED_MODULE_0__.decode)(base64)));\n    return msg;\n}\nclass Throttler {\n    constructor(wait = 100) {\n        if (wait < 0)\n            throw new Error(\"wait must be a positive number\");\n        this.wait = wait;\n        this._reset();\n    }\n    // Try to execute the function immediately, if it is not waiting\n    // If it is waiting, update the function to be called\n    throttle(fn) {\n        if (fn.length > 0)\n            throw new Error(\"fn must not take any arguments\");\n        if (this.isWaiting) {\n            // If the timeout is currently waiting, update the func to be called\n            this.fnToCall = fn;\n        }\n        else {\n            // If there is nothing waiting, call it immediately\n            // and start the throttling\n            fn();\n            this._setTimeout();\n        }\n    }\n    // Execute the function immediately and reset the timeout\n    // This is useful when the timeout is waiting and we want to\n    // execute the function immediately to not have events be out\n    // of order\n    flush() {\n        if (this.fnToCall)\n            this.fnToCall();\n        this._reset();\n    }\n    _setTimeout() {\n        this.timeoutId = setTimeout(() => {\n            if (this.fnToCall) {\n                this.fnToCall();\n                this.fnToCall = null;\n                // Restart the timeout as we just called the function\n                // This call is the key step of Throttler\n                this._setTimeout();\n            }\n            else {\n                this._reset();\n            }\n        }, this.wait);\n    }\n    _reset() {\n        this.fnToCall = null;\n        clearTimeout(this.timeoutId);\n        this.timeoutId = null;\n    }\n    get isWaiting() {\n        return this.timeoutId !== null;\n    }\n}\n\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/utils.ts?");
 
 /***/ }),