# 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`](https://www.hyperfrontend.dev/docs/libraries/features.md) 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](https://demo-heartbeat-production.up.railway.app/host/), whose host keeps the two in separate panels.

## 1. Open the session

```ts
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 v3 security envelope.
  protocol: 'v3',
})
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L105-L114](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L105-L114)</sub>

The watchdog starts judging when the session opens, whether you got there through [`createShell`](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-createShell) or a generated shell package.

## 2. Stamp the product traffic you observe

Product silence is judged from timestamps you record yourself.

```ts
shell.on('beat', (data: unknown) => {
  // note: Receive payloads are schema-validated by the SDK before consumer handlers run.
  const beat = data as BeatPayload
  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)
})
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L118-L138](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L118-L138)</sub>

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

## 3. Subscribe to the SDK's judgement

```ts
shell.on('status', (data: unknown) => {
  // note: The SDK watchdog reports a snapshot object, not a bare state string.
  const status = data as HeartbeatStatus
  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}`)
})
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L157-L165](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L157-L165)</sub>

Each transition hands you a [`HeartbeatStatus`](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-HeartbeatStatus) snapshot. Give each of its four [states](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-HeartbeatState) a different move:

- `healthy`: clear whatever warning you raised.
- `unobservable`: say "can't judge right now", never "offline". Silence carries no information here: a page is hidden and [throttled timers](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) make the quiet meaningless, or watching has just resumed and no beat has arrived yet.
- `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.

```ts
// 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)
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L212-L240](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L212-L240)</sub>

## 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.

```ts
{
  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',
},
```

<sub>e.g. [apps/demos/heartbeat/heartbeat.contract.ts#L16-L29](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/heartbeat.contract.ts#L16-L29)</sub>

Declaring [`respondsWith`](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-ActionDescription-prop-respondsWith) names the reply action so the contract validates the pairing. What resolves the host's [`request`](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-ShellHandle) is the feature registering a handler for the probe.

```ts
// 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() - (reply as PongPayload).sentAt)
    })
    .catch(() => {
      latencyEl.textContent = '—'
    })
}, 2000)
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L194-L208](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L194-L208)</sub>

Skip the probe while [`isOpen`](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-ShellHandle-prop-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.

```ts
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()
})
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L173-L180](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L173-L180)</sub>

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

```ts
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()
})
```

<sub>e.g. [apps/demos/heartbeat/src/host/main.ts#L270-L277](https://github.com/AndrewRedican/hyperfrontend/blob/main/apps/demos/heartbeat/src/host/main.ts#L270-L277)</sub>

## 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 stays `unobservable` for up to about three seconds after they return, and only then goes `suspect`; `healthy` is never announced along the way, because the watchdog only speaks it on an actual beat.
- 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`](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-ShellOptions-prop-onUnresponsive) [policy](https://www.hyperfrontend.dev/docs/libraries/features/host/#api-UnresponsivePolicy) 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.

---

Canonical page: https://www.hyperfrontend.dev/docs/guides/detect-unresponsive-feature/
This file: https://www.hyperfrontend.dev/docs/guides/detect-unresponsive-feature.md
Documentation index: https://www.hyperfrontend.dev/llms.txt
