Background Refresh
When a user navigates away from an instrument, Tango suspends its backend — calling onStop() and clearing all event subscriptions. This prevents inactive instruments from consuming resources or degrading app performance.
If the instrument declares backgroundRefresh in its manifest and implements the onBackgroundRefresh hook, Tango will periodically wake it up with a restricted context to perform lightweight work like fetching data, updating cache, or emitting events.
Lifecycle flow
Section titled “Lifecycle flow”User opens instrument → onStart(ctx) — full backend activeUser navigates away → onStop() — backend suspended → if backgroundRefresh: every N seconds → onBackgroundRefresh(ctx)User returns → stop refresh timer → onStart(ctx) — full backend resumesThe instrument is either active or suspended, never both. When the user returns, any in-flight background tick is terminated immediately before onStart is called.
Manifest configuration
Section titled “Manifest configuration”Declare backgroundRefresh in your package.json under tango.instrument:
{ "tango": { "instrument": { "id": "my-instrument", "entrypoint": "./dist/index.js", "backendEntrypoint": "./dist/backend.js", "backgroundRefresh": { "enabled": true, "intervalSeconds": 30 } } }}backgroundRefresh.enabled
Section titled “backgroundRefresh.enabled”- Type:
boolean - Description: Enables periodic background refresh when the instrument is suspended.
backgroundRefresh.intervalSeconds
Section titled “backgroundRefresh.intervalSeconds”- Type:
number - Minimum:
10(values below 10 are clamped to 10) - Default:
30 - Description: How often (in seconds)
onBackgroundRefreshis called while suspended.
Backend hook
Section titled “Backend hook”Add onBackgroundRefresh to your backend definition:
import { defineBackend, type InstrumentBackendContext, type InstrumentBackgroundRefreshContext,} from "tango-api/backend";
export default defineBackend({ kind: "tango.instrument.backend.v2",
onStart: async (ctx) => { // Full activation — set up monitors, timers, state },
onStop: async () => { // Full cleanup — stop monitors, clear timers },
onBackgroundRefresh: async (ctx) => { // Lightweight work with restricted context const data = await fetch("https://api.example.com/updates"); await ctx.host.storage.setProperty("cachedData", await data.json()); ctx.emit({ event: "data.updated", payload: { count: 5 } }); },
actions: { /* ... */ },});Restricted context
Section titled “Restricted context”The context passed to onBackgroundRefresh is intentionally limited compared to the full InstrumentBackendContext:
type InstrumentBackgroundRefreshContext = { instrumentId: string; permissions: InstrumentPermission[]; emit: (event: Omit<InstrumentEvent, "instrumentId">) => void; logger: LoggerAPI; host: { storage: StorageAPI; settings: InstrumentSettingsAPI; };};What’s available
Section titled “What’s available”| API | Available | Notes |
|---|---|---|
storage | Yes | Full read/write access to properties, files, and SQL |
settings | Yes | Read/write instrument settings |
emit | Yes | Send events (for future badge/notification support) |
logger | Yes | Log messages at any level |
fetch | Yes | Standard HTTP requests (global, not part of ctx) |
What’s NOT available
Section titled “What’s NOT available”| API | Reason |
|---|---|
sessions | Background refresh should not spawn Claude sessions |
connectors | Too heavy for periodic background work |
stages | Not needed for data refresh |
events.subscribe | Subscriptions are cleared on suspend |
This restriction prevents instruments from abusing background refresh to run heavy workloads.
Concurrency and safety
Section titled “Concurrency and safety”The app enforces several protections:
Skip if busy
Section titled “Skip if busy”If a previous tick is still running when the next interval fires, the tick is skipped — not stacked. This prevents unbounded concurrency.
tick @ 10s → starts, takes 15stick @ 20s → previous still running, SKIPtick @ 30s → previous finished at 25s, new tick startsHard timeout
Section titled “Hard timeout”If a tick runs longer than max(30s, intervalSeconds * 2), it is forcefully aborted. The next tick proceeds normally.
tick @ 10s → starts, hangstick @ 20s → skiptick @ 30s → skiptick @ 40s → hard timeout (30s), ABORTtick @ 50s → new tick startsClean handoff
Section titled “Clean handoff”When the user returns to the instrument:
- The background scheduler is stopped immediately
- Any in-flight tick is aborted
onStart(ctx)is called with the full backend context
Actions while suspended
Section titled “Actions while suspended”Backend actions remain callable while the instrument is suspended. This enables future features like badge updates or app shell queries without requiring a full resume.
// This works even when the instrument is suspendedconst result = await api.actions.call("getUnreadCount", {});The action handler receives the full InstrumentBackendContext, but calling an action does not trigger onStart or change the suspended state.
Common patterns
Section titled “Common patterns”Cache-and-display
Section titled “Cache-and-display”Update a cache in background refresh; read it when the frontend mounts:
onBackgroundRefresh: async (ctx) => { const prs = await fetchOpenPRs(); await ctx.host.storage.setProperty("cachedPRs", prs); ctx.emit({ event: "prs.updated", payload: { count: prs.length } });},
actions: { getCachedPRs: { input: { type: "object", properties: {} }, output: { type: "any" }, handler: async (ctx) => { return await ctx.host.storage.getProperty("cachedPRs") ?? []; }, },},// frontend panelfunction PRList() { const api = useInstrumentApi(); const [prs, setPrs] = useState([]);
useEffect(() => { api.actions.call("getCachedPRs", {}).then(setPrs); }, []);
return <UIList>{prs.map(pr => <UIListItem key={pr.id} title={pr.title} />)}</UIList>;}Status monitoring
Section titled “Status monitoring”Check external service status and log it:
onBackgroundRefresh: async (ctx) => { try { const res = await fetch("https://api.example.com/health"); const healthy = res.ok; await ctx.host.storage.setProperty("serviceHealthy", healthy); ctx.logger.info(`Service health check: ${healthy ? "OK" : "DOWN"}`); } catch { await ctx.host.storage.setProperty("serviceHealthy", false); ctx.logger.warn("Service health check failed"); }},