Recipe: Server-Sent Events

How to wire a server SSE feed into a Micra component using only what's already in the framework — state, onCreate, onDestroy — plus the browser's native EventSource.

EventSource onCreate / onDestroy no helper needed
No Micra.stream(…) helper exists, by design. Open / parse / handle errors / close is exactly the kind of thing onCreate / onDestroy already cover. A built-in would hide reconnect / auth / named-event concerns without buying much, so we ship a recipe instead.

1. Minimal — one stream, JSON payload

Server route emits data: {"price": 123.45}\n\n lines. The component subscribes on create, closes on destroy.

<div data-component="live-price">
  <strong data-text="price"></strong>
  <small data-text="status"></small>
</div>
Micra.define('live-price', {
  state: {
    price:  0,
    status: 'connecting',
  },

  onCreate() {
    this._es = new EventSource('/api/prices/stream')

    this._es.onopen = () => {
      this.state.status = 'live'
    }

    this._es.onmessage = (e) => {
      const payload = JSON.parse(e.data)
      this.state.price = payload.price
    }

    this._es.onerror = () => {
      // EventSource auto-reconnects with exponential backoff. We just
      // update the UI; we don't close on error or it won't retry.
      this.state.status = 'reconnecting'
    }
  },

  onDestroy() {
    this._es?.close()
  },
})

Micra.start()
  • EventSource auto-reconnects. Don't close() on error.
  • e.data is always a string. Parse JSON yourself; don't assume the server sends JSON.
  • The stream lives on this._es. Underscore prefix marks it as instance bookkeeping, not state. Closing it in onDestroy is mandatory — otherwise it survives instance.destroy() and silently reconnects forever.

2. Named events (event: name\ndata: …\n\n)

Production feeds usually split traffic by event name. Use addEventListener per event:

Micra.define('order-feed', {
  state: { orders: [], status: 'connecting' },

  onCreate() {
    const es = new EventSource('/api/orders/stream')
    this._es = es

    es.addEventListener('open', () => {
      this.state.status = 'live'
    })

    es.addEventListener('order:created', (e) => {
      const order = JSON.parse(e.data)
      // Replace, don't mutate — Micra tracks top-level writes only.
      this.state.orders = [order, ...this.state.orders]
    })

    es.addEventListener('order:updated', (e) => {
      const order = JSON.parse(e.data)
      this.state.orders = this.state.orders.map(o =>
        o.id === order.id ? order : o,
      )
    })

    es.addEventListener('error', () => {
      this.state.status = 'reconnecting'
    })
  },

  onDestroy() {
    this._es?.close()
  },
})

3. Stream URL from a server-rendered data-* attribute

SSR-friendly pattern: the server picks the stream URL per page (different tenant, different feed), Micra reads it via this.prop().

<div data-component="notification-feed"
     data-stream="/api/notifications/stream?token=abc123"></div>
Micra.define('notification-feed', {
  state: { items: [], status: 'connecting' },

  onCreate() {
    const url = this.prop('stream')
    if (!url) return  // server didn't render a URL — nothing to do
    this._es = new EventSource(url, { withCredentials: true })
    this._es.onmessage = (e) => {
      this.state.items = [JSON.parse(e.data), ...this.state.items]
    }
    this._es.onopen  = () => { this.state.status = 'live' }
    this._es.onerror = () => { this.state.status = 'reconnecting' }
  },

  onDestroy() {
    this._es?.close()
  },
})

4. Manual reconnect with backoff

When you need custom auth refresh or a Last-Event-ID header beyond what the native loop covers, swap in a manual reconnect. More code — only use it when needed.

Micra.define('audit-stream', {
  state: { events: [], status: 'connecting' },

  onCreate() {
    this._closed = false
    this._connect()
  },

  _connect() {
    if (this._closed) return
    const es = new EventSource('/api/audit/stream')
    this._es = es

    es.onopen = () => { this.state.status = 'live' }
    es.onmessage = (e) => {
      this.state.events = [JSON.parse(e.data), ...this.state.events].slice(0, 100)
    }
    es.onerror = () => {
      es.close()
      this.state.status = 'reconnecting'
      this._retry = setTimeout(() => this._connect(), 3000)
    }
  },

  onDestroy() {
    this._closed = true
    clearTimeout(this._retry)
    this._es?.close()
  },
})

5. Multiple streams in one component

state is shared, but you can hold multiple EventSource references:

Micra.define('dashboard', {
  state: { price: 0, volume: 0 },

  onCreate() {
    this._streams = [
      new EventSource('/api/price/stream'),
      new EventSource('/api/volume/stream'),
    ]
    this._streams[0].onmessage = (e) => { this.state.price  = JSON.parse(e.data).value }
    this._streams[1].onmessage = (e) => { this.state.volume = JSON.parse(e.data).value }
  },

  onDestroy() {
    this._streams?.forEach(es => es.close())
  },
})

Things to avoid

  • Don't lose this. Inside an addEventListener('order:created', fn) callback, use arrow functions so this stays the component instance.
  • Don't forget onDestroy. A leaked EventSource keeps reconnecting in the background, holds a server connection, and writes to state on a destroyed instance. (Micra silently ignores those writes — but the network traffic is real.)
  • Don't render lists by hand. Push items into state and let <template data-each> render. Same rule as everywhere else in Micra.
  • Don't auto-parse all e.data blindly. SSE bodies are strings. Some endpoints emit data: heartbeat\n\n (no JSON). Guard with try/catch or filter by event name first.

Backend tips (server-side, not Micra)

  • Always send Content-Type: text/event-stream and disable buffering (X-Accel-Buffering: no for nginx).
  • Heartbeats: emit :ping\n\n every ~15 s so proxies don't drop the connection. Comments (:) are ignored by EventSource.
  • retry: 5000\n\n lets the server suggest a reconnect interval to the browser when it disconnects.
  • Frameworks: ActionController::Live (Rails), StreamedResponse (Laravel / Symfony), StreamingHttpResponse (Django), raw res.write with the right headers (Node / Express).