Core Concepts

Job tracker

How Flux watches a long-running backend job — video encoding, CSV parsing, an AI job — to completion, over either a realtime channel or a polling fallback, through one shared interpreter that doesn't care which transport is active.

Prerequisites
  • A registered store, if using static job tracking — see api/register
  • A realtime adapter, if using the realtime transport — see adapters/supabase or similar
  • @tsworldtech/flux-react installed, if using the built-in FluxJobPanel component
1

Why two transports, one interpreter

A job doesn't finish in the request that starts it — something has to watch it. Flux supports two ways of watching, both funneling into the exact same event interpreter.
typescript
1// A backend job — video encoding, CSV parsing, an AI generation
2// call, image optimization — doesn't finish in the same request that
3// starts it. Something needs to watch it and report back.
4//
5// Flux supports two transports for that watching, unified behind
6// ONE interpreter function so the calling code never has to know or
7// care which one is active:
8
9// Transport A — realtime. The job's row lives in a table Flux
10// already has a channel for. Every UPDATE is a normal realtime
11// event, interpreted the same way any other row change would be.
12
13// Transport B — polling. No realtime table for this job at all —
14// common for a queue-backed worker with no live row to subscribe to.
15// A configured pollFn is called on an interval instead.
16
17// Both transports funnel into the exact same interpreter:
18// processJobTrackerEvent(). It has no idea which transport produced
19// the event it's looking at, and doesn't need to.
2

Static registration — the common case

A job tied to a store you've already registered. No separate call needed to start tracking — it piggybacks on the registration itself.
typescript
1// The common case — a job type tied to a store you've already
2// registered. jobTracker piggybacks on that registration entirely;
3// no separate call needed to start tracking.
4
5flux.register({
6 store: videoJobsStore,
7 channel: 'video_jobs',
8 event: 'UPDATE',
9 idbKey: 'video_jobs',
10 ttl: 'short',
11 scope: 'user',
12 jobTracker: {
13 trackBy: 'job_id',
14 scope: 'user', // inherits reg.scope if omitted — see Step 5
15 onProgress: (store, percent) => store.setEncodeProgress(percent),
16 onComplete: (store, payload) => store.markEncodeDone(payload),
17 onFailure: (store, error) => store.markEncodeFailed(error),
18 },
19})
20
21// Because table is set on the registration, this uses the realtime
22// transport — every UPDATE on video_jobs runs through the
23// interpreter. If table were omitted and pollFn were provided
24// instead, the same config would use polling. table is ignored for
25// jobTracker purposes if pollFn is also set AND table is undefined —
26// see Step 3 for exactly when each transport wins.
3

How a row becomes a status

The interpreter checks a fixed, priority-ordered list of field names and terminal values — not a schema you configure, a vocabulary you map your rows onto.
typescript
1// The interpreter doesn't require a fixed row shape — it checks a
2// priority-ordered list of common field names, so it works against
3// whatever your backend already calls these columns.
4
5// Status field — checked in this order, first match wins:
6// status, job_status, state, jobState
7
8// Progress field — checked in this order:
9// progress, percent, percent_complete
10
11// Terminal SUCCESS values (any of these fires onComplete, once):
12// complete, completed, done, finished, success, succeeded
13
14// Terminal FAILURE values (any of these fires onFailure, once):
15// failed, failure, error, cancelled, canceled
16
17// All of the above are Flux-native conventions — not project-
18// specific strings you invent. If your backend uses something
19// outside this list (e.g. a numeric status enum), map it to one of
20// these strings before Flux ever sees the row, rather than expecting
21// the interpreter to learn a new vocabulary.
22
23// Every terminal value — success or failure — fires its callback
24// EXACTLY ONCE per job. Internal state cleans up immediately on that
25// transition, so a job can't accidentally fire onComplete twice from
26// two UPDATE events that both happen to carry the same terminal
27// status.
4

The polling transport

For jobs with no realtime table to subscribe to — same config shape, same callbacks, different source of events.
typescript
1// The polling path — for jobs with no realtime table to subscribe
2// to. Configured on the same jobTracker object, just swap table for
3// pollFn:
4
5flux.register({
6 store: csvJobsStore,
7 channel: 'csv_jobs',
8 idbKey: 'csv_jobs',
9 ttl: 'short',
10 // no "table" field — this store never opens a realtime channel
11 jobTracker: {
12 trackBy: 'job_id',
13 pollFn: () => fetch('/api/csv-jobs/status').then(r => r.json()),
14 pollIntervalMs: 3000, // default — see the recommended values below
15 onProgress: (store, percent) => store.setParseProgress(percent),
16 onComplete: (store, payload) => store.markParseDone(payload),
17 onFailure: (store, error) => store.markParseFailed(error),
18 },
19})
20
21// pollIntervalMs recommendations, by urgency:
22// 1000-1500ms high urgency — payment verification, AI streaming fallback
23// 3000ms default — image optimization, CSV parsing
24// 10000-30000ms long-running — video encoding, deep database audits
25
26// The effective interval is pollIntervalMs PLUS however long the
27// fetch itself takes — the next poll only starts once the current
28// one fully resolves. This prevents request stacking if the network
29// is slow; it does NOT guarantee exactly-every-N-ms polling.
30
31// IMPORTANT: pollFn is ignored entirely if reg.table IS set. Setting
32// both table and pollFn on the same jobTracker config doesn't merge
33// them — realtime wins, pollFn is silently unused. Pick one
34// transport per job type.
5

Polling is leader-only

Only the tab holding the realtime leader lock actually polls — every other open tab's poller stays dormant.
typescript
1// Polling doesn't run on every open tab — only the tab that holds
2// the realtime leader lock (Section 4.4) actually polls. Every other
3// tab's poller exists but stays dormant.
4
5// registerStore() creates a JobPoller for every tab, but never calls
6// .start() itself. The poller only activates when THIS tab wins the
7// realtime leader Web Lock and RealtimeManager calls
8// registry.notifyLeaderElected(), which promotes every stored poller
9// to active.
10
11// This means: 5 tabs open on a page with a pollFn-based job tracker
12// -> exactly 1 tab is actually calling pollFn on an interval, not 5.
13// If the leader tab closes, leadership transfers (Section 4.4) and
14// the newly-elected tab's poller picks up polling from there —
15// there's a brief gap during transfer, not a guaranteed
16// zero-interruption handoff.
17
18// Polling also pauses automatically on visibilitychange to hidden
19// and the offline event, and only resumes on visible/online if this
20// tab is STILL the leader at that point — a tab that lost leadership
21// while backgrounded does not resume polling when it becomes visible
22// again.
6

Dynamic tracking — jobs that don't exist yet

The canonical case is an offline upload: no job_id exists until the queued intent actually replays and the server hands one back.
typescript
1// Not every job exists at registration time. The offline-upload
2// pattern (offline/upload-pattern) is the canonical example: a file
3// is queued while offline, and there's no job_id to track until the
4// upload actually lands and the server hands one back.
5
6// flux.trackJob() covers exactly this — starting to track a job
7// AFTER something else (usually onReplaySuccess) discovers its id:
8
9queueConfig: {
10 storeName: 'uploads',
11 replayEndpoint: '/api/uploads',
12 onReplaySuccess: (responseBody, entry) => {
13 flux.trackJob(responseBody.job_id, uploadsStore, {
14 trackBy: 'job_id',
15 scope: 'user', // see the callout below — this is NOT optional
16 // in practice for a private upload flow
17 onProgress: (store, percent) => store.setUploadProgress(percent),
18 onComplete: (store, payload) => store.markUploadDone(payload),
19 onFailure: (store, error) => store.markUploadFailed(error),
20 })
21 },
22}
23
24// trackJob() returns a cleanup function, same contract as register():
25const stopTracking = flux.trackJob(jobId, store, config)
26// stopTracking() // unregisters this dynamic job early if ever needed
7

Scope inheritance — where it works automatically, and where it doesn't

This is the most common mistake in job tracking on private data — static and dynamic registration behave differently here, not just as an edge case.
typescript
1// scope resolution differs between the two registration paths — one
2// inherits automatically, the other doesn't, and this is the single
3// most common mistake with job tracking on private data.
4
5// STATIC registration (jobTracker on a StoreRegistration) — inherits
6// automatically if omitted:
7// config.scope ?? parentRegistration.scope ?? 'global'
8
9flux.register({
10 channel: 'dashboard_jobs',
11 scope: 'user', // <- jobTracker below inherits THIS
12 jobTracker: {
13 trackBy: 'job_id',
14 // no scope set here — resolves to 'user' automatically
15 },
16})
17
18// DYNAMIC registration (flux.trackJob()) — has no parent
19// registration to inherit FROM. There is nothing for it to fall back
20// to except the hardcoded 'global' default:
21
22flux.trackJob(jobId, store, {
23 trackBy: 'job_id',
24 // scope omitted here -> 'global', ALWAYS, regardless of whose
25 // upload this is. A private user upload tracked this way will
26 // NOT be cleared by clearUserSession() unless scope: 'user' is
27 // set explicitly, every single time.
28})
trackJob() never inherits scope

A dynamic job has no parent registration to pull scope from. Omitting it doesn't inherit anything — it resolves to 'global' every time, silently surviving a logout it should have been cleared by.

8

Clearing tracked jobs — two functions, two purposes

Using the unconditional wipe from a logout handler destroys global job trackers that have nothing to do with the user leaving.
typescript
1// Two different teardown functions exist, and using the wrong one
2// either wipes too much or too little:
3
4// clearJobTrackerState() — unconditional FULL wipe. Called only from
5// RealtimeManager.destroy() and engine.destroy() — genuine app
6// teardown, not logout. Calling this from a logout handler would
7// also destroy a public system-status job tracker that has nothing
8// to do with the user who's leaving.
9
10// clearJobTrackerByScope('user') — selective purge. This is what
11// flux.clearUserSession() actually calls (Section 4.17) — removes
12// only 'user'-scoped jobStates and dynamicJobs entries, leaves every
13// 'global'-scoped job tracker untouched.
14
15await flux.clearUserSession()
16// internally calls clearJobTrackerByScope('user') as one step among
17// several (queue purge, IDB sweep, anchor clearing — Section 4.17)
18
19// This is exactly why the scope: 'user' step above matters so much
20// for trackJob() — a dynamic job tracked without an explicit scope
21// silently survives logout, tracking progress for a job that belongs
22// to a session that no longer exists.
9

Quick path — import FluxJobPanel as-is

Covers seven states out of the box, including the two that pair with the upload pattern.
typescript
1// Everything the quick-import path needs from @tsworldtech/flux-react:
2
3import { FluxJobPanel } from '@tsworldtech/flux-react'
4import type { FluxJobPanelProps, JobStatus, PendingIntent } from '@tsworldtech/flux-react'
5
6function UploadStatus() {
7 const { job, progress, status, isQueued, pendingIntent, isOnline } = useJobState()
8
9 return (
10 <FluxJobPanel
11 job={job}
12 progress={progress}
13 status={status}
14 isQueued={isQueued}
15 pendingIntent={pendingIntent}
16 isOnline={isOnline}
17 onRequestReselect={() => openFilePicker()}
18 />
19 )
20}
21
22// The panel covers seven states out of the box: idle, running,
23// complete, failed, cancelled (see the callout below), queued
24// offline, and pending re-select — the last two designed specifically
25// to slot in next to the FluxUploadGate pattern from
26// offline/upload-pattern.
10

The cancelled state isn't a Flux mechanism

Worth reading before relying on this state as shipped — it's the component author's own convention, not something the tracker itself defines.
typescript
1// One thing to know before relying on the "cancelled" state as
2// shipped: it is NOT a Flux mechanism. Section 4.7's terminal
3// FAILURE values are generic — failed, failure, error, cancelled,
4// canceled — and a job matching any of them fires onFailure. There
5// is no separate 'cancelled' status Flux itself distinguishes.
6
7// FluxJobPanel layers its own convention on top: it inspects
8// job.error and looks for the EXACT string 'Cancelled by user' to
9// decide whether to show the amber "cancelled" state instead of the
10// red "failed" state:
11
12function isCancelledJob(job) {
13 return job?.error === 'Cancelled by user'
14}
15
16// This string has to come from YOUR OWN server-side cancel endpoint
17// setting job.error to exactly that value — it is not something
18// Flux's tracker sets automatically, and no such convention is
19// documented anywhere in the core engine. If your backend uses a
20// different string, a different field, or a numeric cancel reason
21// code, this check silently never matches and every cancellation
22// renders as a plain "Job failed" instead. Update the string (or the
23// whole check) to match your own API before relying on this
24// distinction.
This will silently never match against a different backend

If your cancel endpoint doesn't set job.error to exactly 'Cancelled by user', every cancellation renders as a plain failure — there's no warning or fallback signal that the check missed.

11

Why this one is worth copying instead

Beyond the usual styling reasons, this component needs a real logic edit for most backends, not just a recolor.
typescript
1// Same reasoning as the upload gate and conflict panel: FluxJobPanel
2// is fully inline-styled with its own hardcoded COLORS object,
3// unrelated to any host app's design tokens, with no theming prop or
4// className pass-through into its internal elements.
5//
6// It's also the component most likely to need a REAL structural
7// edit, not just a recolor — the cancelled-state detection above is
8// baked to one specific backend convention that almost certainly
9// isn't yours. Importing this as a black box means either matching
10// your cancel endpoint's error string to Flux's default, or losing
11// the cancelled/failed distinction entirely. Copying it means fixing
12// isCancelledJob() once, in your own copy, to match however your own
13// backend actually reports a cancellation.
12

Full source — copy this into your project

Two edits worth making immediately: the cancelled-job check, and the color tokens.
typescript
1// packages/flux-react/src/components/FluxJobPanel.tsx
2//
3// Copy into your own project. The two edits almost everyone will
4// want to make immediately: the COLORS object, and isCancelledJob()
5// to match your own backend's cancel convention instead of the
6// 'Cancelled by user' default.
7
8import React from 'react'
9
10export type JobStatus = 'idle' | 'running' | 'complete' | 'failed'
11
12export interface PendingIntent {
13 label: string
14 fileName: string
15 fileSize: number
16 fileType: string
17 lastModified: number
18}
19
20export interface FluxJobPanelProps {
21 job: { id: string; label?: string; error?: string | null; [key: string]: any } | null
22 progress: number
23 status: JobStatus
24 isQueued: boolean
25 pendingIntent: PendingIntent | null
26 isOnline: boolean
27 onRequestReselect?: () => void
28 className?: string
29}
30
31function formatBytes(bytes: number): string {
32 if (bytes === 0) return '0 B'
33 const k = 1024
34 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
35 const i = Math.floor(Math.log(bytes) / Math.log(k))
36 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
37}
38
39// ── EDIT THIS — match your own cancel-endpoint's convention ────────
40function isCancelledJob(job: FluxJobPanelProps['job']): boolean {
41 return job?.error === 'Cancelled by user' // <- your string here,
42 // or a different field
43 // entirely (job.reason,
44 // job.cancelledBy, etc)
45}
46
47// ── EDIT THIS — your own design tokens, not Flux's defaults ────────
48const COLORS = {
49 border: 'var(--flux-job-border, #1e3a42)',
50 surface: 'var(--flux-job-surface, #05242e)',
51 text: 'var(--flux-job-text, #f2fcff)',
52 muted: 'var(--flux-job-muted, #7db8c8)',
53 accent: 'var(--flux-job-accent, #156A80)', // swapped from package
54 // default blue to
55 // your site teal
56 green: '#22c55e',
57 amber: '#f59e0b',
58 red: '#ef4444',
59}
60
61function ProgressBar({ value }: { value: number }) {
62 const clamped = Math.max(0, Math.min(100, value))
63 return (
64 <div style={styles.progressTrack}>
65 <div style={{ ...styles.progressFill, width: `${clamped}%` }} />
66 </div>
67 )
68}
69
70function StatusDot({ color }: { color: string }) {
71 return <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', backgroundColor: color, flexShrink: 0 }} />
72}
73
74export function FluxJobPanel({
75 job, progress, status, isQueued, pendingIntent, isOnline, onRequestReselect, className,
76}: FluxJobPanelProps) {
77
78 if (isQueued && pendingIntent && !isOnline) {
79 return (
80 <div style={styles.panel} className={className}>
81 <div style={styles.row}><StatusDot color={COLORS.amber} /><span style={styles.label}>Saved offline</span></div>
82 <p style={styles.meta}>{pendingIntent.fileName} · {formatBytes(pendingIntent.fileSize)}</p>
83 <p style={styles.hint}>Reconnect to upload and start this job.</p>
84 </div>
85 )
86 }
87
88 if (isQueued && pendingIntent && isOnline) {
89 return (
90 <div style={{ ...styles.panel, borderColor: COLORS.amber }} className={className}>
91 <div style={styles.row}><StatusDot color={COLORS.amber} /><span style={styles.label}>Ready to upload</span></div>
92 <p style={styles.meta}>{pendingIntent.fileName} · {formatBytes(pendingIntent.fileSize)}</p>
93 <p style={styles.hint}>You're back online. Re-select the file to continue.</p>
94 <button style={styles.button} onClick={onRequestReselect} type="button">Re-select File</button>
95 </div>
96 )
97 }
98
99 if (status === 'complete') {
100 return (
101 <div style={styles.panel} className={className}>
102 <div style={styles.row}><StatusDot color={COLORS.green} /><span style={styles.label}>Job complete</span></div>
103 {job && <p style={styles.meta}>ID: {job.id}</p>}
104 </div>
105 )
106 }
107
108 if (status === 'failed' && isCancelledJob(job)) {
109 return (
110 <div style={{ ...styles.panel, borderColor: COLORS.amber }} className={className}>
111 <div style={styles.row}><StatusDot color={COLORS.amber} /><span style={styles.label}>Job cancelled</span></div>
112 {job && <p style={styles.meta}>ID: {job.id}</p>}
113 </div>
114 )
115 }
116
117 if (status === 'failed') {
118 return (
119 <div style={{ ...styles.panel, borderColor: COLORS.red }} className={className}>
120 <div style={styles.row}><StatusDot color={COLORS.red} /><span style={styles.label}>Job failed</span></div>
121 {job?.error && <p style={styles.hint}>{job.error}</p>}
122 {job && <p style={styles.meta}>ID: {job.id}</p>}
123 </div>
124 )
125 }
126
127 if (status === 'running' && job) {
128 return (
129 <div style={styles.panel} className={className}>
130 <div style={styles.row}>
131 <StatusDot color={COLORS.accent} />
132 <span style={styles.label}>{job.label ?? 'Processing…'}</span>
133 <span style={styles.pct}>{Math.round(progress)}%</span>
134 </div>
135 <ProgressBar value={progress} />
136 <p style={styles.meta}>ID: {job.id}</p>
137 </div>
138 )
139 }
140
141 return (
142 <div style={{ ...styles.panel, ...styles.idle }} className={className}>
143 <span style={styles.idleText}>No active job</span>
144 </div>
145 )
146}
147
148const styles: Record<string, React.CSSProperties> = {
149 panel: { display: 'flex', flexDirection: 'column', gap: 8, padding: '14px 16px', borderRadius: 10, border: `1px solid ${COLORS.border}`, backgroundColor: COLORS.surface, color: COLORS.text, fontFamily: 'inherit', fontSize: 14, minWidth: 240 },
150 row: { display: 'flex', alignItems: 'center', gap: 8 },
151 label: { fontWeight: 600, flex: 1, color: COLORS.text },
152 pct: { fontSize: 13, color: COLORS.muted, fontVariantNumeric: 'tabular-nums' },
153 meta: { margin: 0, fontSize: 12, color: COLORS.muted },
154 hint: { margin: 0, fontSize: 12, color: COLORS.muted, lineHeight: 1.5 },
155 progressTrack: { height: 6, borderRadius: 3, backgroundColor: '#0d3340', overflow: 'hidden' },
156 progressFill: { height: '100%', borderRadius: 3, backgroundColor: COLORS.accent, transition: 'width 0.2s ease' },
157 button: { alignSelf: 'flex-start', padding: '6px 12px', borderRadius: 6, border: `1px solid ${COLORS.accent}`, backgroundColor: 'transparent', color: COLORS.accent, fontSize: 13, fontWeight: 600, cursor: 'pointer', lineHeight: 1 },
158 idle: { alignItems: 'center', justifyContent: 'center', padding: '20px 16px' },
159 idleText: { color: COLORS.muted, fontSize: 13 },
160}
Fix isCancelledJob() first

This is the one edit that changes behavior, not just appearance — do it before the color pass, or cancellations will keep rendering as failures in your copy too.

13

A licensing note

What's gated here, and why there's no degraded fallback tier for this one.
typescript
1// Job tracking (both static and dynamic, both transports) is a
2// Pro/Enterprise-tier capability (Section 4.12's tier matrix) — not
3// console-enforced against the component itself, but the underlying
4// engine methods (trackJob, and jobTracker on register()) are what's
5// actually gated. Free and Starter tiers don't have a meaningful
6// fallback here the way fire_and_forget covers handshake replay's
7// gate — there's no degraded version of job tracking below Pro.

For the offline upload flow this pairs with, see offline/upload-pattern. For the multi-tenant scoping referenced in Step 7, see core-concepts/multi-tenant-scoping. For the full trackJob() signature, see api/track-job.