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 v3 security envelope.
protocol: 'v3',
})
e.g. apps/demos/heartbeat/src/host/main.ts#L105-L114
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 = 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)
})
e.g. apps/demos/heartbeat/src/host/main.ts#L118-L138
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 = 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}`)
})
e.g. apps/demos/heartbeat/src/host/main.ts#L157-L165
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". Silence carries no information here: a page is hidden and throttled timers 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.
// 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#L212-L240
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() - (reply as PongPayload).sentAt)
})
.catch(() => {
latencyEl.textContent = '—'
})
}, 2000)
e.g. apps/demos/heartbeat/src/host/main.ts#L194-L208
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#L173-L180
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#L270-L277
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
healthywith zero missed beats. - Switch to another tab for a few seconds and come back. The state goes
unobservable, thenhealthy. - Close, then reopen. The state walks to
goneand back tohealthy.
Limits
suspectneeds 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 staysunobservablefor up to about three seconds after they return, and only then goessuspect;healthyis 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
onUnresponsivepolicy 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.
suspectearns its keep with cross-origin features, which browsers typically isolate into their own processes.
Reference and background
Next steps
How to close a feature without losing unsaved work
The host owns the frame and can tear it out whenever it likes, but only the embedded feature knows whether a draft, an armed timer, or an unsent edit is still inside, and removing an iframe fires nothing the feature can act on.
How to compose independently shipped features on one page
I need several independently built, independently deployed apps working together on one page, coordinating with each other, without merging codebases and without one failure taking down the rest.