Skip to main content
Stateful activities span multiple sessions. The client SDK tracks time per session via heartbeats, and the server records completion when the activity is truly done.
For info on shared concepts, see the Activity Tracking overview.

Architecture

Stateful activities split responsibility between frontend and backend:
ResponsibilityOwnerMechanism
Time trackingFrontendHeartbeats via activity.start()
StateBackendYour app’s database
CompletionBackendtimeback.activity.record()
A runId ties all sessions together. The SDK generates one automatically when you call activity.start(), but you can also provide your own. Any unique string works. If your app already has a unique identifier for the activity attempt (e.g. an assignment row ID), pass it as runId directly instead of storing a separate value. On resume, pass the same runId so every heartbeat and the final completion event share the same identifier.

Frontend

The frontend tracks time per session and orchestrates the student’s workflow (starting, resuming, and submitting answers). But completion must come from the server: across multiple sessions, it’s the only part of the system that has the full picture of the student’s accumulated progress.

Starting a session

const activity = timeback.activity.start({
	id: 'lesson-1',
	name: 'Fractions',
	course: { subject: 'Math', grade: 3 },
})

// Your API — persist the runId so you can pass it back on resume
await saveProgress({ lessonId: 'lesson-1', runId: activity.runId })

Resuming a session

const progress = await loadProgress({ lessonId: 'lesson-1' })

const activity = timeback.activity.start({
	id: 'lesson-1',
	name: 'Fractions',
	course: { subject: 'Math', grade: 3 },
	runId: progress.runId, // reuse the saved runId
})

Ending a session

This is the key difference from single-session activities: the student can leave at any point — close the tab, navigate away, or simply stop for the day — without losing their progress. Calling activity.end() flushes the accumulated time but does not record a completion. The activity stays open, ready to be resumed later with the same runId.
await activity.end() // Flushes time, no completion
The frontend always calls activity.end() without metrics. Completion is recorded by the server.

Framework examples

import { useEffect, useRef } from 'react'
import { useTimeback } from '@timeback/sdk/react'

function ResumableLesson({ lessonId, lessonName }) {
	const timeback = useTimeback()
	const activityRef = useRef(null)

	useEffect(() => {
		if (!timeback) return

		async function startTracking() {
			// Your API — load saved progress (if any)
			const res = await fetch(`/api/progress/${lessonId}`)
			const progress = await res.json()

			activityRef.current = timeback.activity.start({
				id: lessonId,
				name: lessonName,
				course: { subject: 'Math', grade: 3 },
				...(progress?.runId && { runId: progress.runId }),
			})

			// Your API — persist runId on first start
			if (!progress?.runId) {
				await fetch(`/api/progress/${lessonId}`, {
					method: 'POST',
					body: JSON.stringify({ runId: activityRef.current.runId }),
				})
			}
		}

		startTracking()

		return () => {
			// Flush time on unmount — server handles completion
			void activityRef.current?.end()
		}
	}, [timeback, lessonId, lessonName])

	return <div>Lesson content...</div>
}

Backend

When a student completes an activity, as determined by your app’s logic, the backend records the result using timeback.activity.record(). This sends an ActivityCompletedEvent to the Caliper API.

Basic usage

await timeback.activity.record({
	user: { email: '[email protected]' },
	activity: {
		id: 'lesson-1',
		name: 'Fractions',
		course: { code: 'MATH-3' },
	},
	metrics: {
		totalQuestions: 20,
		correctQuestions: 16,
		xpEarned: 150,
		masteredUnits: 2,
		pctComplete: 100,
	},
	runId: savedProgress.runId, // Correlate with frontend heartbeats
})

With time data (advanced)

You will rarely, if ever, need this pattern. The frontend SDK already tracks time via heartbeats. Only use the time parameter if your backend independently tracks accumulated time (e.g. from offline sync or batch imports) and you need to override what the heartbeats reported.
await timeback.activity.record({
	user: { email: '[email protected]' },
	activity: {
		id: 'lesson-1',
		name: 'Fractions',
		course: { code: 'MATH-3' },
	},
	metrics: {
		totalQuestions: 20,
		correctQuestions: 16,
		xpEarned: 150,
	},
	time: {
		activeMs: 1200000, // 20 minutes total active time
		inactiveMs: 120000, // 2 minutes total paused time
	},
	runId: savedProgress.runId,
})

API reference

user
object
required
Student identity. Provide email (required) and optionally timebackId. See Identity for how users are resolved.
activity
object
required
Activity identity.
metrics
object
required
Completion metrics.
time
object
Optional time data. Include when the backend tracks accumulated session time.
runId
string
UUID correlating this completion with frontend heartbeats. Should match the runId persisted when the activity was started on the client.
totalQuestions and correctQuestions must be provided together.If you provide one, you must provide the other.

Best practices

The runId is the correlation key between frontend heartbeats and backend completion. Save it to your database as soon as the activity starts.
When a student starts a new attempt of a previously completed activity, do not reuse the old runId. Omit it from activity.start() to generate a fresh one — otherwise new heartbeats would be incorrectly correlated with the old completion event.
Completion should come from the backend via timeback.activity.record(). The frontend should always use activity.end() (no metrics) to flush time data only.

Next steps