Offline Patterns

Upload pattern

A file upload can't be queued the same way a form submission can — a File object doesn't survive a tab close. This page covers the three scenarios that actually matter: online, offline with the tab open, and offline with the tab closed and reopened — plus the fingerprint-matching component that makes the third one usable.

Prerequisites
  • A registered store with jobTracker config — see core-concepts/job-tracker
  • A queueConfig set up for the upload endpoint — see core-concepts/offline-queue
  • @tsworldtech/flux-react installed, if using the built-in FluxUploadGate component
1

Why uploads need their own pattern

Every other queued mutation in these docs is plain JSON. A file is not — and the gap between those two things is a real browser security boundary, not something any library can paper over.
typescript
1// Every other offline pattern in these docs queues a JSON-serializable
2// payload — a form submission, a settings change, a comment. A file
3// upload is different in one specific way that breaks the naive
4// version of "just queue it":
5
6flux.register({
7 channel: 'uploads',
8 queueConfig: {
9 storeName: 'uploads',
10 replayEndpoint: '/api/uploads',
11 },
12 // ...
13})
14
15// addQueueEntrySafe({ payload: selectedFile, ... })
16// ^^^^^^^^^^^^
17// A File object holds a reference to something on the user's actual
18// disk. It's NOT plain JSON. It survives being held in memory for as
19// long as the tab stays open — but the moment the tab closes, that
20// reference is gone. There is no API that lets a browser reopen a
21// tab and hand you back the same File object it had before. This
22// isn't a Flux limitation — it's a deliberate browser security
23// boundary that no offline library can route around.
24//
25// So "queue the upload" actually means three different things
26// depending on what happens between selection and replay.
2

Three scenarios, not one

"Handle offline uploads" actually splits into three genuinely different code paths depending on what happens between file selection and successful replay.
typescript
1// The three scenarios this page covers — same three named explicitly
2// as a documentation requirement in Section 15's OPFS note:
3
4// 1. Online, direct upload
5// No queueing needed at all. Upload immediately, track the job
6// immediately. This is the fast path — most uploads never touch
7// anything below.
8
9// 2. Offline, tab stays open
10// The File object is still alive in memory. Queue an intent
11// (metadata only) alongside a reference to the in-memory File.
12// On reconnect, the replay handler still has the actual file
13// and uploads it directly — no user interaction needed.
14
15// 3. Offline, tab closes and reopens (or the browser restarts)
16// The File reference is gone — this is the case the browser
17// security boundary above actually bites. Flux can restore the
18// intent's METADATA from IDB, but not the bytes. The user has
19// to re-select the file, and Flux verifies it's very likely the
20// same one before uploading — this is what FluxUploadGate does.
21
22// A file above roughly 5MB held across an offline tab-CLOSE is OPFS
23// territory (Section 15) — deferred, not covered by this page. What
24// follows assumes small-to-medium files using the IDB metadata
25// fallback.
3

Register the store — queue the intent, not the file

The shape every scenario below builds on: a plain-JSON PendingIntent object goes into the queue, never the File itself.
typescript
1// Store the INTENT, not the file. This is the metadata that survives
2// a tab close via ordinary IDB persistence — a File object never
3// touches the queue directly.
4
5interface PendingIntent {
6 label: string // human-readable, shown in the re-select UI
7 fileName: string
8 fileSize: number
9 fileType: string
10 lastModified: number // the fingerprint field that matters most
11}
12
13flux.register({
14 store: uploadsStore,
15 channel: 'uploads',
16 idbKey: 'uploads:pending',
17 ttl: 'long',
18 scope: 'user', // per-tenant — see core-concepts/multi-tenant-scoping
19 queueConfig: {
20 storeName: 'uploads',
21 replayEndpoint: '/api/uploads',
22 method: 'POST',
23 replayStrategy: 'fire_and_forget', // append-only — a new upload
24 // has no existing server row
25 // to conflict with
26 },
27 jobTracker: {
28 trackBy: 'job_id',
29 scope: 'user',
30 onProgress: (store, percent) => store.setUploadProgress(percent),
31 onComplete: (store, payload) => store.markUploadDone(payload),
32 onFailure: (store, error) => store.markUploadFailed(error),
33 },
34})
4

Scenario 1 — online, direct upload

The fast path. No queue involved on the way in.
typescript
1// Scenario 1 — online, direct upload. No Flux queue involved at all
2// on the way in; the file goes straight to the server and job
3// tracking starts the moment a job_id comes back.
4
5async function handleFileSelect(file: File) {
6 if (navigator.onLine) {
7 const res = await fetch('/api/uploads', {
8 method: 'POST',
9 body: buildFormData(file),
10 })
11 const { job_id } = await res.json()
12
13 // realtime UPDATE events for this job_id now fire onProgress /
14 // onComplete / onFailure from the jobTracker config above
15 flux.trackJob(job_id, uploadsStore, { scope: 'user' })
16 return
17 }
18
19 // offline — fall through to scenario 2
20 queueUploadIntent(file)
21}
5

Scenario 2 — offline, tab stays open

The File is still alive in memory — keep it there, persist only the metadata alongside it.
typescript
1// Scenario 2 — offline, tab stays open. The File object is still
2// alive in a closure/ref, so keep it there — do NOT try to persist
3// it to IDB. Persist only the metadata, which is what survives if
4// this tab happens to close before reconnecting (falling through to
5// scenario 3 automatically, with no separate code path needed).
6
7const pendingFileRef = useRef<File | null>(null)
8
9function queueUploadIntent(file: File) {
10 pendingFileRef.current = file
11
12 const intent: PendingIntent = {
13 label: 'Profile video upload',
14 fileName: file.name,
15 fileSize: file.size,
16 fileType: file.type,
17 lastModified: file.lastModified,
18 }
19
20 addQueueEntrySafe({
21 storeName: 'uploads',
22 payload: intent, // metadata only — plain JSON, survives IDB
23 endpoint: '/api/uploads',
24 method: 'POST',
25 })
26}
27
28// The actual upload only happens in the replay handler, and only
29// runs if the in-memory File reference is still present:
30queueConfig: {
31 storeName: 'uploads',
32 replayEndpoint: '/api/uploads',
33 onReplaySuccess: async (responseBody, entry) => {
34 // fires once the queued entry is confirmed replayed — this is
35 // the hook that starts job tracking for a just-landed upload
36 // (Section 4.7)
37 flux.trackJob(responseBody.job_id, uploadsStore, { scope: 'user' })
38 },
39}
40
41// NOTE: the default fire_and_forget replay sends entry.payload
42// (the intent, metadata only) to replayEndpoint — it does NOT know
43// how to attach a File. For real uploads, override the replay path
44// with a custom trigger that checks pendingFileRef.current before
45// falling back to the "ask the user to re-select" flow in scenario 3.
6

Scenario 3 — offline, tab closed and reopened

The scenario FluxUploadGate exists for. The metadata survived in IDB; the file itself didn't — the user re-selects, and the gate verifies it's very likely the same file.
typescript
1// Scenario 3 — offline, tab closed and reopened. pendingFileRef is
2// gone (it lived in JS memory, not IDB) — but the intent metadata
3// queued in scenario 2 is still sitting in IDB, exactly where it was
4// left. This is the moment FluxUploadGate exists for: show the user
5// what was queued, let them re-select, and verify it's very likely
6// the same file before uploading.
7
8import { FluxUploadGate } from '@tsworldtech/flux-react'
9
10function PendingUploadCard({ intent }: { intent: PendingIntent }) {
11 return (
12 <div className="upload-card">
13 <p>Resume upload: {intent.label}</p>
14 <p className="text-muted">{intent.fileName}</p>
15
16 <FluxUploadGate
17 pendingIntent={intent}
18 onMatch={(file, match) => {
19 // 'exact' — silent pass-through, no dialog was shown
20 // 'similar' — user confirmed through the built-in dialog
21 // 'different' — user explicitly chose "upload anyway"
22 uploadAndStart(file, intent)
23 }}
24 onClear={() => clearIntent(intent)}
25 >
26 {({ open }) => (
27 <button onClick={open}>Re-select File</button>
28 )}
29 </FluxUploadGate>
30 </div>
31 )
32}
7

Starting job tracking once the upload lands

Job tracking always starts after the upload succeeds — there's nothing to track while a file is still sitting in the queue as an unresolved intent.
typescript
1// One detail worth being explicit about: trackJob() only makes sense
2// once a job_id exists, and a job_id only exists after the server has
3// accepted the upload. There is no job to track while a file is
4// sitting in the queue as an unresolved intent — job tracking always
5// starts AFTER the upload lands, never before (Section 4.7):
6
7// File selected, offline -> queued as PendingIntent
8// (no job_id yet, nothing to track)
9// Reconnect / re-select happens -> file actually uploads
10// Server responds with job_id -> flux.trackJob(job_id, ...) starts
11// Realtime UPDATE events arrive -> onProgress / onComplete fire
12
13// If this upload belongs to a logged-in user's private session
14// (almost always true for uploads), set scope: 'user' on the
15// trackJob config explicitly — dynamic jobs registered via
16// flux.trackJob() have no parent StoreRegistration to inherit scope
17// from, so this is the one place scope doesn't default correctly on
18// its own. See core-concepts/multi-tenant-scoping.
Set scope explicitly for trackJob()

Dynamic jobs registered via flux.trackJob() have no parent registration to inherit scope from — this is the one spot it won't default to 'user' on its own even for obviously private uploads.

8

Quick path — import it as-is

Everything the gate needs, if the default styling is fine for your app.
typescript
1// Everything the quick-import path needs from @tsworldtech/flux-react:
2
3import {
4 FluxUploadGate,
5 checkFileMatch,
6} from '@tsworldtech/flux-react'
7
8import type {
9 FileMatch, // 'exact' | 'similar' | 'different'
10 PendingIntent, // { label, fileName, fileSize, fileType, lastModified }
11 FluxUploadGateProps,
12} from '@tsworldtech/flux-react'
13
14// checkFileMatch(queued, selected) is exported standalone — useful if
15// you want to build your own re-select UI from scratch instead of
16// using FluxUploadGate's built-in dialog, while still reusing the
17// exact fingerprinting logic Flux ships with:
18
19const match = checkFileMatch(intent, selectedFile)
20// 'exact' — size, type, AND lastModified all match
21// 'similar' — size and type match, timestamp differs (likely edited)
22// 'different' — size or type doesn't match (likely the wrong file)
9

Why this one is worth copying instead

The matching logic is worth reusing as-is. The dialog is worth owning outright — here's why.
typescript
1// FluxUploadGate ships as a real, importable component — but it's
2// built with inline styles and a hardcoded color object, not your
3// app's design tokens:
4
5const COLORS = {
6 surface: '#05242e',
7 border: '#1e3a42',
8 blue: '#0088ac',
9 amber: '#f59e0b',
10 red: '#ef4444',
11}
12
13// There's no theming prop, no CSS variable hook, no className
14// pass-through into the dialog itself. That's a deliberate tradeoff,
15// not an oversight: a file-matching dialog is exactly the kind of UI
16// every app wants to look like ITS app, not like a generic library
17// default with someone else's blue in it.
18//
19// The matching LOGIC (checkFileMatch, the exact/similar/different
20// classification, the queue integration) is the part worth reusing
21// as-is. The DIALOG is the part worth owning outright. So: import
22// the logic, copy the component.
10

Full source — copy this into your project

Paste this into your own components folder and edit freely. The only thing still worth importing from the package is checkFileMatch, to keep the exact fingerprinting logic without reimplementing it.
typescript
1// packages/flux-react/src/components/FluxUploadGate.tsx
2//
3// Copy this file into your own project (e.g. components/FluxUploadGate.tsx)
4// and edit the COLORS object, the dialog markup, or the button styles
5// freely — it's yours once it's copied. The only import that still
6// needs to come from the package is checkFileMatch, if you want to
7// keep using Flux's exact matching logic rather than reimplementing it.
8
9import React, { useRef, useState, useCallback } from 'react'
10import { checkFileMatch } from '@tsworldtech/flux-react'
11import type { FileMatch, PendingIntent } from '@tsworldtech/flux-react'
12
13export interface FluxUploadGateProps {
14 pendingIntent: PendingIntent | null
15 onMatch: (file: File, match: FileMatch) => void
16 onMismatch?: (file: File, match: FileMatch) => void
17 onCancel?: () => void
18 accept?: string
19 children?: (props: { open: () => void }) => React.ReactNode
20 className?: string
21}
22
23function formatBytes(bytes: number): string {
24 if (bytes === 0) return '0 B'
25 const k = 1024
26 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
27 const i = Math.floor(Math.log(bytes) / Math.log(k))
28 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
29}
30
31// ── EDIT THIS — your own design tokens, not Flux's defaults ────────
32const COLORS = {
33 surface: 'var(--surface, #05242e)',
34 border: 'var(--border, #1e3a42)',
35 text: 'var(--text, #f2fcff)',
36 muted: 'var(--muted, #7db8c8)',
37 accent: 'var(--accent, #156A80)', // swapped to match your teal,
38 // not the package default blue
39 amber: '#f59e0b',
40 red: '#ef4444',
41}
42
43function MatchDialog({
44 match, selectedFile, intent, onUploadThis, onPickAgain, onCancel,
45}: {
46 match: 'similar' | 'different'
47 selectedFile: File
48 intent: PendingIntent
49 onUploadThis: () => void
50 onPickAgain: () => void
51 onCancel?: () => void
52}) {
53 const isSimilar = match === 'similar'
54
55 return (
56 <div style={styles.overlay}>
57 <div style={styles.dialog} role="dialog" aria-modal="true">
58 <div style={{ ...styles.iconWrap, backgroundColor: isSimilar ? '#3b2a00' : '#2a0a0a' }}>
59 <span style={{ fontSize: 20 }}>{isSimilar ? '⚠️' : '❌'}</span>
60 </div>
61
62 <h3 style={styles.dialogTitle}>
63 {isSimilar ? 'File may have been modified' : "File doesn't match saved job"}
64 </h3>
65
66 <p style={styles.dialogBody}>
67 {isSimilar ? (
68 <>This file looks like it may have been modified since you queued this job.
69 The original was <strong>{intent.fileName}</strong> ({formatBytes(intent.fileSize)}).
70 Continue anyway?</>
71 ) : (
72 <>The selected file (<strong>{selectedFile.name}</strong>, {formatBytes(selectedFile.size)})
73 doesn't match your saved job (<strong>{intent.fileName}</strong>, {formatBytes(intent.fileSize)}).
74 Upload this file instead, or pick the correct one?</>
75 )}
76 </p>
77
78 <div style={styles.dialogActions}>
79 {isSimilar ? (
80 <>
81 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onUploadThis} type="button">Upload This</button>
82 <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onPickAgain} type="button">Pick Again</button>
83 </>
84 ) : (
85 <>
86 <button style={{ ...styles.btn, ...styles.btnWarn }} onClick={onUploadThis} type="button">Upload This Anyway</button>
87 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onPickAgain} type="button">Pick Correct File</button>
88 {onCancel && <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onCancel} type="button">Cancel</button>}
89 </>
90 )}
91 </div>
92 </div>
93 </div>
94 )
95}
96
97export function FluxUploadGate({
98 pendingIntent, onMatch, onMismatch, onCancel, accept, children, className,
99}: FluxUploadGateProps) {
100 const inputRef = useRef<HTMLInputElement>(null)
101 const [pendingFile, setPendingFile] = useState<File | null>(null)
102 const [pendingMatch, setPendingMatch] = useState<FileMatch | null>(null)
103
104 const open = useCallback(() => inputRef.current?.click(), [])
105
106 const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
107 const file = e.target.files?.[0]
108 if (!file) return
109 e.target.value = ''
110
111 if (!pendingIntent) { onMatch(file, 'exact'); return }
112
113 const match = checkFileMatch(pendingIntent, file)
114 if (match === 'exact') { onMatch(file, 'exact'); return }
115 if (onMismatch) { onMismatch(file, match); return }
116
117 setPendingFile(file)
118 setPendingMatch(match)
119 }, [pendingIntent, onMatch, onMismatch])
120
121 const handleUploadThis = useCallback(() => {
122 if (pendingFile && pendingMatch) onMatch(pendingFile, pendingMatch)
123 setPendingFile(null)
124 setPendingMatch(null)
125 }, [pendingFile, pendingMatch, onMatch])
126
127 const handlePickAgain = useCallback(() => {
128 setPendingFile(null)
129 setPendingMatch(null)
130 setTimeout(() => inputRef.current?.click(), 50)
131 }, [])
132
133 const handleCancel = useCallback(() => {
134 setPendingFile(null)
135 setPendingMatch(null)
136 onCancel?.()
137 }, [onCancel])
138
139 const acceptType = accept ?? pendingIntent?.fileType ?? undefined
140
141 return (
142 <div style={{ display: 'contents' }} className={className}>
143 <input ref={inputRef} type="file" accept={acceptType} style={{ display: 'none' }} onChange={handleChange} aria-hidden="true" />
144
145 {children ? children({ open }) : (
146 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={open} type="button">Re-select File</button>
147 )}
148
149 {pendingFile && pendingMatch && pendingMatch !== 'exact' && pendingIntent && (
150 <MatchDialog
151 match={pendingMatch as 'similar' | 'different'}
152 selectedFile={pendingFile}
153 intent={pendingIntent}
154 onUploadThis={handleUploadThis}
155 onPickAgain={handlePickAgain}
156 onCancel={pendingMatch === 'different' ? handleCancel : undefined}
157 />
158 )}
159 </div>
160 )
161}
162
163const styles: Record<string, React.CSSProperties> = {
164 overlay: { position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 13, 17, 0.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, backdropFilter: 'blur(4px)' },
165 dialog: { display: 'flex', flexDirection: 'column', gap: 16, padding: 24, borderRadius: 12, border: `1px solid ${COLORS.border}`, backgroundColor: COLORS.surface, color: COLORS.text, fontFamily: 'inherit', fontSize: 14, maxWidth: 420, width: 'calc(100vw - 48px)', boxShadow: '0 24px 48px rgba(0,0,0,0.5)' },
166 iconWrap: { alignSelf: 'flex-start', padding: '8px 10px', borderRadius: 8 },
167 dialogTitle: { margin: 0, fontSize: 16, fontWeight: 700, color: COLORS.text, lineHeight: 1.3 },
168 dialogBody: { margin: 0, color: COLORS.muted, lineHeight: 1.6 },
169 dialogActions: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 },
170 btn: { padding: '8px 14px', borderRadius: 6, fontSize: 13, fontWeight: 600, cursor: 'pointer', lineHeight: 1, border: '1px solid transparent', transition: 'opacity 0.15s' },
171 btnPrimary: { backgroundColor: COLORS.accent, color: '#fff', border: `1px solid ${COLORS.accent}` },
172 btnWarn: { backgroundColor: 'transparent', color: COLORS.amber, border: `1px solid ${COLORS.amber}` },
173 btnGhost: { backgroundColor: 'transparent', color: COLORS.muted, border: `1px solid ${COLORS.border}` },
174}
This is a copy-paste component, not a black box

Once copied, it's your file. Rename it, restyle it, swap the dialog for a toast, whatever fits — Flux only owns checkFileMatch() and the PendingIntent shape underneath it.

11

A licensing note, since this pattern touches Pro features

Worth being upfront about which parts of this pattern are tier-gated and which aren't.
typescript
1// FluxUploadGate is a Pro/Enterprise-tier pattern — not because the
2// component itself is license-gated (it isn't; license enforcement
3// is console-only and never blocks a feature, Section 4.12), but
4// because dynamic job tracking and handshake-quality queue config
5// are what this pattern is actually built on top of, and those ARE
6// tier-gated (Section 4.12's tier matrix). Free tier can still queue
7// a fire_and_forget upload intent — it just won't have live job
8// progress to show while it uploads.

For the job tracking callbacks referenced throughout, see core-concepts/job-tracker. For the queue mechanics underneath this pattern, see core-concepts/offline-queue. Large files held across an offline tab close (roughly above 5MB) are OPFS territory — deferred, see Section 15 of the master doc.