{"id":1859,"date":"2017-06-02T00:00:00","date_gmt":"2017-06-01T22:00:00","guid":{"rendered":"https:\/\/wwwneu.strehle.de\/tim\/weblog\/archives\/2017\/06\/02\/1619-2\/"},"modified":"2025-07-31T21:50:48","modified_gmt":"2025-07-31T19:50:48","slug":"1619-2","status":"publish","type":"post","link":"https:\/\/www.strehle.de\/tim\/weblog\/archives\/2017\/06\/02\/1619-2\/","title":{"rendered":"Streaming an Ajax response with Vue.js and Server-sent events (SSE)"},"content":{"rendered":"\n<p>The problem: We want to display a <strong>large number of search results<\/strong> (from our DAM system) on a Web page. Gathering the results on the server and transferring them to the browser takes a while. To improve the user experience and show the first results as soon as possible, <strong>we want to \u201cstream\u201d the results<\/strong>. Each item needs to be rendered as soon as the browser receives it. A simple Ajax call waits until the server has returned everything, so we\u2019ll have to do something a little more advanced.<\/p>\n\n\n\n<p>The solution outlined here combines two technologies I currently enjoy experimenting with: the <a href=\"https:\/\/vuejs.org\">Vue.js JavaScript framework<\/a> (a competitor of <a href=\"https:\/\/vuejs.org\/v2\/guide\/comparison.html\">React and Angular<\/a>) and <a href=\"https:\/\/en.wikipedia.org\/wiki\/Server-sent_events\">Server-sent events (SSE)<\/a>, a lightweight, W3C-standardized alternative to <a href=\"https:\/\/en.wikipedia.org\/wiki\/WebSocket\">WebSockets<\/a>.<\/p>\n\n\n\n<p>In our old UI, we used <a href=\"http:\/\/oboejs.com\">Oboe.js<\/a> for streaming, but I like the new approach much better because it requires little code, thanks to <strong>the magic of Vue.js<\/strong> and <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/EventSource\">EventSource<\/a> (the Web browser\u2019s built-in SSE support, not available in IE and Microsoft Edge) \u2013 and because it\u2019s <strong>very lightweight<\/strong>, requiring nothing but a simple script include (no npm, no build toolchain).<\/p>\n\n\n\n<p>The end result looks like this, you can <a href=\"\/tim\/demos\/stream.html\">try it live here<\/a>:<\/p>\n\n\n\n<figure class=\"wp-block-video\"><\/figure>\n\n\n\n<p>Let\u2019s see how this works:<\/p>\n\n\n\n<p>Server-sent events use a <strong>simple text protocol<\/strong>. Here\u2019s what my server sends; pretty self-explanatory (note the <code>text\/event-stream<\/code> content type):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ curl -v http:\/\/example.com\/stream.php\n&gt; GET \/stream.php HTTP\/1.1\n&gt; \n&lt; HTTP\/1.1 200 OK\n&lt; Content-Type: text\/event-stream; charset=UTF-8\n&lt; \nevent:header\ndata:{\"total_items\":20,\"msg\":\"Hello from server\"}\n\nevent:item\ndata:{\"cnt\":1,\"text\":\"&#91;1] Hello from server\"}\n\nevent:item\ndata:{\"cnt\":2,\"text\":\"&#91;2] Hello from server\"}\n\n&#91;\u2026]\n\nevent:item\ndata:{\"cnt\":20,\"text\":\"&#91;20] Hello from server\"}\n\nevent:close\ndata:&#91;]\n\n<\/code><\/pre>\n\n\n\n<p>This format is transparently handled by the <code>EventSource<\/code> object. We just need it to feed data into a Vue.js model. <strong>Vue.js updates the HTML<\/strong> automatically when the model changes. (Check out the <a href=\"https:\/\/vuejs.org\/v2\/guide\/\">Vue.js documentation<\/a>.)<\/p>\n\n\n\n<p>It\u2019s important to know that <code>EventSource<\/code> is designed for permanent connections so it keeps requesting the same URL in an endless loop when the server closes the connection. The <code>evtSource.close()<\/code> call prevents that, stopping after the server has signalled that all results have been delivered.<\/p>\n\n\n\n<p>It\u2019s all in a single HTML file, <code>stream.html<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;html&gt;\n  &lt;head&gt;\n    &lt;meta charset=\"UTF-8\"\/&gt;\n  &lt;\/head&gt;\n  &lt;body&gt;\n    &lt;!-- This div is rendered by Vue.js: --&gt;\n    &lt;div id=\"app\"&gt;\n      &lt;form v-on:submit.prevent=\"run\"&gt;\n        &lt;input v-model=\"msg\" type=\"text\" placeholder=\"Enter message\"\/&gt;\n        &lt;button v-on:click=\"run\" type=\"button\"&gt;{{ buttonLabel }}&lt;\/button&gt;\n      &lt;\/form&gt;\n      &lt;p&gt;{{ items.length }} of {{ total_items }} times \u201c{{ actual_msg }}\u201d:&lt;\/p&gt;\n      &lt;ul&gt;\n        &lt;my-item v-for=\"item in items\" :key=\"item.cnt\" v-bind:item=\"item\"&gt;\n        &lt;\/my-item&gt;\n      &lt;\/ul&gt;\n    &lt;\/div&gt;\n    &lt;!-- Include Vue.js: --&gt;\n    &lt;script src=\"https:\/\/unpkg.com\/vue\/dist\/vue.min.js\"&gt;&lt;\/script&gt;\n    &lt;!-- Our own JavaScript code: --&gt;\n    &lt;script type=\"application\/javascript\"&gt;\n\n    Vue.component('my-item', {\n      props: &#91;'item'],\n      template: '&lt;li&gt;{{ item.text }}&lt;\/li&gt;'\n    });\n\n    var evtSource = false;\n    \n    var app = new Vue({\n      el: '#app',\n      data: {\n        msg: 'Hello world',\n        actual_msg: '',\n        total_items: -1,\n        items: &#91;],\n        loading: false\n      },\n      computed: {\n        buttonLabel: function() {\n          return (this.loading ? 'Loading\u2026' : 'Go');\n        }\n      },\n      methods: {\n        run: function() {\n    \n          this.reset();\n      \n          var streamUrl = 'stream.php'\n            + '?msg=' + encodeURIComponent(this.msg);\n      \n          evtSource = new EventSource(streamUrl);\n          this.loading = true;\n\n          var that = this;\n\n          evtSource.addEventListener('header', function (e) {\n            var header = JSON.parse(e.data);\n            that.total_items = header.total_items;\n            that.actual_msg = header.msg;\n          }, false);\n\n          evtSource.addEventListener('item', function (e) {\n            var item = JSON.parse(e.data);\n            that.items.push(item);\n          }, false);\n\n          evtSource.addEventListener('close', function (e) {\n            evtSource.close();\n            that.loading = false;\n          }, false);\n        },\n        reset: function() {\n          if (evtSource !== false) {\n            evtSource.close();\n          }\n\n          this.loading = false;\n          this.items = &#91;];\n          this.total_items = -1;\n        }\n      }\n    })\n\n    &lt;\/script&gt;\n  &lt;\/body&gt;\n&lt;\/html&gt;\n<\/code><\/pre>\n\n\n\n<p>For completeness, the <strong>PHP code<\/strong> <code>stream.php<\/code> producing the above output, which is not that interesting:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\n\/\/ Parameters\n\n$repeat = 20;\n\n$msg = filter_input\n(\n    INPUT_GET,\n    'msg',\n    FILTER_SANITIZE_FULL_SPECIAL_CHARS,\n    &#91;'options' =&gt; &#91;'default' =&gt; '']]\n);\n\nif ($msg === '') {\n    $msg = 'Hello from server';\n}\n\n\/\/ Make sure output buffering is turned off\n@ob_end_flush();\n\n\/\/ Header\n\nheader('Content-Type: text\/event-stream; charset=UTF-8');\n\necho dataToStreamEvent\n(\n    'header', \n    &#91;'total_items' =&gt; $repeat, 'msg' =&gt; $msg]\n);\nflush();\n\n\/\/ Message times repeat\n\nfor ($cnt = 1; $cnt &lt;= $repeat; $cnt++) {\n    usleep(50000);\n    echo dataToStreamEvent\n    (\n        'item', \n        &#91;'cnt' =&gt; $cnt, 'text' =&gt; sprintf('&#91;%s] %s', $cnt, $msg)]\n    );\n    flush();\n}\n\n\/\/ Tell client to close the connection\n\necho dataToStreamEvent('close', &#91;]);\nflush();\n\n\/**\n * @param string $type\n * @param array $data\n * @param string $id\n * @return string text\/event-stream formatted string\n *\/\nfunction dataToStreamEvent($type, array $data, $id = '')\n{\n    $result = '';\n\n    if ($type !== '') {\n        $result .= sprintf(\"event:%s\\n\", strtr($type, &#91;\"\\r\" =&gt; ' ', \"\\n\" =&gt; ' ']));\n    }\n\n    if ($id !== '') {\n        $result .= sprintf(\"id:%s\\n\", strtr($id, &#91;\"\\r\" =&gt; ' ', \"\\n\" =&gt; ' ']));\n    }\n\n    $result .= \"data:\" . strtr(json_encode($data), &#91;\"\\r\\n\" =&gt; \"\\n\", \"\\r\" =&gt; \"\\n\", \"\\n\" =&gt; \"\\ndata:\"]);\n\n    $result .= \"\\n\\n\";\n\n    return $result;\n}\n<\/code><\/pre>\n\n\n\n<p>What do you think of this approach? Let me know on <a href=\"https:\/\/twitter.com\/tistre\">Twitter<\/a> or by <a href=\"mailto:tim@strehle.de\">e-mail<\/a>.<\/p>\n\n\n\n<p>(To keep the examples short, I intentionally omitted error handling and documentation. Don\u2019t use it in production like this!)<\/p>\n\n\n\n<p><em>Update (2017-06-03):<\/em> See also Chris Blackwell describing his solution for <a href=\"https:\/\/chrisblackwell.me\/server-sent-events-using-laravel-vue\/\">Server Sent Events using Laravel and Vue<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The problem: We want to display a large number of search results (from our DAM system) on a Web page. Gathering the results on the server and transferring them to the browser takes a while. To improve the user experience and show the first results as soon as possible, we want to \u201cstream\u201d the results. [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_share_on_mastodon":"0"},"categories":[1],"tags":[],"class_list":["post-1859","post","type-post","status-publish","format-standard","hentry","category-weblog"],"share_on_mastodon":{"url":"","error":""},"_links":{"self":[{"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/posts\/1859","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/comments?post=1859"}],"version-history":[{"count":1,"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/posts\/1859\/revisions"}],"predecessor-version":[{"id":1902,"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/posts\/1859\/revisions\/1902"}],"wp:attachment":[{"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/media?parent=1859"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/categories?post=1859"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.strehle.de\/tim\/wp-json\/wp\/v2\/tags?post=1859"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}