How-toCode from a running demointermediate~20 minBuilt on the heartbeat demo →
@hyperfrontend/features

How to detect and handle an unresponsive feature

Notice a failing feature within seconds, tell the visitor something honest, measure how well the connection is performing, and recover when the feature comes back.

An embedded feature is someone else's runtime living in your page. It can hang, crash, lose its renderer process, or sit in a background tab where the browser throttles its timers to a crawl, and none of that announces itself. Two signals answer two different questions, and either can be healthy while the other is not: the @hyperfrontend/features watchdog says the feature's frame is alive, your own contract events say it is doing its job. Watch both. The snippets come from the heartbeat demo, whose host keeps the two in separate panels.

1. Open the session

const shell = createShell({
  modes: { embedded: mountEmbedded },
  container: '#stage',
  name: '@hyperfrontend/demo-heartbeat',
  // why: Resolved against this page, so the same markup works under `hf dev`, `vite preview`, and any static origin serving the pair.
  url: new URL('/', window.location.href).toString(),
  contract,
  // why: Matches the feature's declared protocol, so the pairing negotiates the v1 security envelope.
  protocol: 'v1',
})

e.g. apps/demos/heartbeat/src/host/main.ts#L97-L106

The watchdog starts judging when the session opens, whether you got there through createShell or a generated shell package.

2. Stamp the product traffic you observe

Product silence is judged from timestamps you record yourself.

shell.on('beat', (data: unknown) => {
  // note: Receive payloads are schema-validated by the SDK before consumer handlers run.
  const beat = <BeatPayload>data
  const receivedAt = Date.now()
  totalBeats += 1
  if (beat.source === 'user') {
    userBeats += 1
    log(`user beat #${beat.seq}`)
    // why: An extra beat is the visitor's doing — the host celebrates it with a toast sticker drifting off the heart.
    fx.spawnToast()
  }
  // why: Any beat ends a flatline, so the skull leaves immediately even mid-materialisation.
  fx.hideSkull()
  lastBeatAt = receivedAt
  rolling.addBeat(receivedAt)
  ecg.addBeat({ at: receivedAt, source: beat.source })
  // note: Silent until the visitor approves sound; a flatlined rhythm emits no beats, so nothing can sound while flat.
  audio.playBeat()
  beatsTotalEl.textContent = String(totalBeats)
  beatsUserEl.textContent = String(userBeats)
})

e.g. apps/demos/heartbeat/src/host/main.ts#L110-L130

Derive any displayed rate from the events you actually received, never from the feature's configured target.

3. Subscribe to the SDK's judgement

shell.on('status', (data: unknown) => {
  // note: The SDK watchdog reports a snapshot object, not a bare state string.
  const status = <HeartbeatStatus>data
  sdkStateEl.textContent = status.state
  sdkStateEl.dataset['state'] = status.state
  sdkMissedEl.textContent = String(status.missedBeats)
  sdkLastBeatEl.textContent = status.lastBeatAt === null ? '—' : `${Math.max(0, Date.now() - status.lastBeatAt)} ms ago`
  log(`sdk liveness: ${status.state}`)
})

e.g. apps/demos/heartbeat/src/host/main.ts#L149-L157

Each transition hands you a HeartbeatStatus snapshot. Give each of its four states a different move:

  • healthy: clear whatever warning you raised.
  • unobservable: say "can't judge right now", never "offline", because throttled timers make silence meaningless.
  • suspect: decide per product. Report it verbatim when connection state is the product; do not demote a working embed on suspicion alone.
  • gone: drop the session-scoped state you inferred from beats.

4. Judge product silence separately

Product silence is yours to define, from two inputs: the feature's own admission through a contract event, and silence measured against the last cadence you observed.

// how: The vitals tick twice a second: rolling BPM from received intervals, plus the flatline judgement from state or silence.
setInterval(() => {
  const now = Date.now()
  const flat = rhythmState === 'flatline' || isFlatline(now, lastBeatAt, lastKnownBpm)
  ecg.setFlat(flat)
  // how: The edge detector fires 'show' once per flatline period; the skull otherwise manages its own 10-second lifetime.
  const skullEdge = flatlineEdge.evaluate(flat)
  if (skullEdge === 'show') {
    fx.showSkull()
  } else if (skullEdge === 'hide') {
    fx.hideSkull()
  }
  if (flat) {
    ecgFlagEl.hidden = false
    ecgFlagEl.textContent = 'FLATLINE'
    ecgFlagEl.dataset['kind'] = 'flatline'
    bpmEl.textContent = '0'
  } else {
    const bpm = rolling.bpmAt(now)
    bpmEl.textContent = bpm === null ? '—' : String(bpm)
    if (rhythmState === 'recovering') {
      ecgFlagEl.hidden = false
      ecgFlagEl.textContent = 'RECOVERING'
      ecgFlagEl.dataset['kind'] = 'recovering'
    } else {
      ecgFlagEl.hidden = true
    }
  }
}, 500)

e.g. apps/demos/heartbeat/src/host/main.ts#L204-L232

5. Measure the round trip

Latency on top of state tells you how well the connection is performing, not just whether it is up. Send a probe on an interval and have the feature echo the send time back, so the difference covers the whole path.

{
  type: 'ping',
  description:
    'Latency probe. Answered directly when sent as a request, and always echoed as a `pong` event carrying the original `sentAt`.',
  schema: {
    type: 'object',
    properties: {
      seq: { type: 'number' },
      sentAt: { type: 'number' },
    },
    required: ['seq', 'sentAt'],
  },
  respondsWith: 'pong',
},

e.g. apps/demos/heartbeat/heartbeat.contract.ts#L16-L29

Declaring respondsWith names the reply action so the contract validates the pairing. What resolves the host's request is the feature registering a handler for the probe.

// how: The latency probe is a correlated request — the feature's responder echoes sentAt, and the pong event fires for plain listeners too.
setInterval(() => {
  if (!shell.isOpen) {
    return
  }
  pingSeq += 1
  void shell
    .request('ping', { seq: pingSeq, sentAt: Date.now() })
    .then((reply) => {
      latencyEl.textContent = String(Date.now() - (<PongPayload>reply).sentAt)
    })
    .catch(() => {
      latencyEl.textContent = '—'
    })
}, 2000)

e.g. apps/demos/heartbeat/src/host/main.ts#L186-L200

Skip the probe while isOpen is false, and handle rejection: requests reject when the channel closes before the reply arrives.

6. Reset your judgement when the session closes

A session that closes takes your product judgement with it, so reset everything you inferred from beats. Mid-session closes are often reloads: the SDK adopts the new document and traffic resumes, so stale judgement must not bleed into the new session.

shell.on('close', () => {
  log('channel closed')
  rolling.reset()
  lastBeatAt = null
  // why: The host stops judging a rhythm it no longer observes — otherwise closing mid-flatline pins the flag and skull forever.
  rhythmState = 'beating'
  fx.hideSkull()
})

e.g. apps/demos/heartbeat/src/host/main.ts#L165-L172

A suspect feature that resumes beating needs no reset at all. The next beat ends the episode and the watchdog re-arms.

el<HTMLButtonElement>('#close-btn').addEventListener('click', () => {
  shell.close()
})

el<HTMLButtonElement>('#reopen-btn').addEventListener('click', () => {
  // note: open() after close() remounts the same shell; a full destroy() is only for releasing the handle for good.
  shell.open()
})

e.g. apps/demos/heartbeat/src/host/main.ts#L262-L269

Check it worked

Your host must walk these transitions without you touching the feature's code:

  • Stop the feature's product traffic while leaving the frame alive. Your product-silence flag raises; the SDK state stays healthy with zero missed beats.
  • Switch to another tab for a few seconds and come back. The state goes unobservable, then healthy.
  • Close, then reopen. The state walks to gone and back to healthy.

Limits

  • suspect needs visible silence, and returning to visibility grants a fresh miss budget, because throttled beats need time to resume. A feature that died while the visitor was on another tab reports unobservable, then healthy for up to about three seconds after they return, and only then suspect. Wait for actual traffic before you call it recovered.
  • The watchdog runs a fixed cadence of one beat a second against a budget of three. For a different silence budget, run your own deadline over product events, and set an onUnresponsive policy to choose what the shell does when the budget trips.
  • A same-origin feature shares the host's thread, so a busy spin freezes the host page with it and no watchdog anywhere gets to run. suspect earns its keep with cross-origin features, which browsers typically isolate into their own processes.

Reference and background

Next steps