Skip to content

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.

User opens instrument → onStart(ctx) — full backend active
User navigates away → onStop() — backend suspended
→ if backgroundRefresh:
every N seconds → onBackgroundRefresh(ctx)
User returns → stop refresh timer → onStart(ctx) — full backend resumes

The instrument is either active or suspended, never both. When the user returns, any in-flight background tick is terminated immediately before onStart is called.

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
}
}
}
}
  • Type: boolean
  • Description: Enables periodic background refresh when the instrument is suspended.
  • Type: number
  • Minimum: 10 (values below 10 are clamped to 10)
  • Default: 30
  • Description: How often (in seconds) onBackgroundRefresh is called while suspended.

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: { /* ... */ },
});

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;
};
};
APIAvailableNotes
storageYesFull read/write access to properties, files, and SQL
settingsYesRead/write instrument settings
emitYesSend events (for future badge/notification support)
loggerYesLog messages at any level
fetchYesStandard HTTP requests (global, not part of ctx)
APIReason
sessionsBackground refresh should not spawn Claude sessions
connectorsToo heavy for periodic background work
stagesNot needed for data refresh
events.subscribeSubscriptions are cleared on suspend

This restriction prevents instruments from abusing background refresh to run heavy workloads.

The app enforces several protections:

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 15s
tick @ 20s → previous still running, SKIP
tick @ 30s → previous finished at 25s, new tick starts

If a tick runs longer than max(30s, intervalSeconds * 2), it is forcefully aborted. The next tick proceeds normally.

tick @ 10s → starts, hangs
tick @ 20s → skip
tick @ 30s → skip
tick @ 40s → hard timeout (30s), ABORT
tick @ 50s → new tick starts

When the user returns to the instrument:

  1. The background scheduler is stopped immediately
  2. Any in-flight tick is aborted
  3. onStart(ctx) is called with the full backend context

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 suspended
const 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.

Update a cache in background refresh; read it when the frontend mounts:

backend.ts
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 panel
function 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>;
}

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");
}
},