Offline Patterns

Upload pattern

A file upload cannot be queued the same way a form submission can — a File object does not 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 is 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 is 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:
2
3// 1. Online, direct upload
4// No queueing needed at all. Upload immediately, track the job
5// immediately. This is the fast path — most uploads never touch
6// anything below.
7
8// 2. Offline, tab stays open
9// The File object is still alive in memory. Queue an intent
10// (metadata only) alongside a reference to the in-memory File.
11// On reconnect, the replay handler still has the actual file
12// and uploads it directly — no user interaction needed.
13
14// 3. Offline, tab closes and reopens (or the browser restarts)
15// The File reference is gone — this is the case the browser
16// security boundary above actually bites. Flux can restore the
17// intent's METADATA from IDB, but not the bytes. The user has
18// to re-select the file, and Flux verifies it is very likely the
19// same one before uploading — this is what FluxUploadGate does.
20
21// A file above roughly 5MB held across an offline tab-CLOSE is OPFS
22// territory — deferred, not covered by this page. What follows
23// assumes small-to-medium files using the IDB metadata 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 flux.trackJob(responseBody.job_id, uploadsStore, { scope: 'user' })
37 },
38}
39
40// NOTE: the default fire_and_forget replay sends entry.payload
41// (the intent, metadata only) to replayEndpoint — it does NOT know
42// how to attach a File. For real uploads, override the replay path
43// with a custom trigger that checks pendingFileRef.current before
44// 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 did not — the user re-selects, and the gate verifies it is 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 is 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-[var(--color-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 is 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:
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 will not 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 is why.
typescript
1// FluxUploadGate ships as a real, importable component — but it is
2// built with inline styles and a hardcoded color object, not your
3// app's design tokens:
4
5const COLORS = {
6 surface: 'var(--color-surface, #05242e)',
7 border: 'var(--color-border, #1e3a42)',
8 blue: 'var(--color-accent, #0088ac)',
9 amber: '#f59e0b',
10 red: '#ef4444',
11}
12
13// There is no theming prop, no CSS variable hook, no className
14// pass-through into the dialog itself. That is 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 is yours once it is 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(--color-surface, #05242e)',
34 border: 'var(--color-border, #1e3a42)',
35 text: 'var(--color-foreground, #f2fcff)',
36 muted: 'var(--color-muted, #7db8c8)',
37 accent: 'var(--color-accent, #156A80)',
38 amber: '#f59e0b',
39 red: '#ef4444',
40}
41
42function MatchDialog({
43 match, selectedFile, intent, onUploadThis, onPickAgain, onCancel,
44}: {
45 match: 'similar' | 'different'
46 selectedFile: File
47 intent: PendingIntent
48 onUploadThis: () => void
49 onPickAgain: () => void
50 onCancel?: () => void
51}) {
52 const isSimilar = match === 'similar'
53
54 return (
55 <div style={styles.overlay}>
56 <div style={styles.dialog} role="dialog" aria-modal="true">
57 <div style={{ ...styles.iconWrap, backgroundColor: isSimilar ? '#3b2a00' : '#2a0a0a' }}>
58 <span style={{ fontSize: 20 }}>{isSimilar ? '⚠️' : '❌'}</span>
59 </div>
60
61 <h3 style={styles.dialogTitle}>
62 {isSimilar ? 'File may have been modified' : "File doesn't match saved job"}
63 </h3>
64
65 <p style={styles.dialogBody}>
66 {isSimilar ? (
67 <>This file looks like it may have been modified since you queued this job.
68 The original was <strong>{intent.fileName}</strong> ({formatBytes(intent.fileSize)}).
69 Continue anyway?</>
70 ) : (
71 <>The selected file (<strong>{selectedFile.name}</strong>, {formatBytes(selectedFile.size)})
72 doesn't match your saved job (<strong>{intent.fileName}</strong>, {formatBytes(intent.fileSize)}).
73 Upload this file instead, or pick the correct one?</>
74 )}
75 </p>
76
77 <div style={styles.dialogActions}>
78 {isSimilar ? (
79 <>
80 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onUploadThis} type="button">Upload This</button>
81 <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onPickAgain} type="button">Pick Again</button>
82 </>
83 ) : (
84 <>
85 <button style={{ ...styles.btn, ...styles.btnWarn }} onClick={onUploadThis} type="button">Upload This Anyway</button>
86 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onPickAgain} type="button">Pick Correct File</button>
87 {onCancel && <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onCancel} type="button">Cancel</button>}
88 </>
89 )}
90 </div>
91 </div>
92 </div>
93 )
94}
95
96export function FluxUploadGate({
97 pendingIntent, onMatch, onMismatch, onCancel, accept, children, className,
98}: FluxUploadGateProps) {
99 const inputRef = useRef<HTMLInputElement>(null)
100 const [pendingFile, setPendingFile] = useState<File | null>(null)
101 const [pendingMatch, setPendingMatch] = useState<FileMatch | null>(null)
102
103 const open = useCallback(() => inputRef.current?.click(), [])
104
105 const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
106 const file = e.target.files?.[0]
107 if (!file) return
108 e.target.value = ''
109
110 if (!pendingIntent) { onMatch(file, 'exact'); return }
111
112 const match = checkFileMatch(pendingIntent, file)
113 if (match === 'exact') { onMatch(file, 'exact'); return }
114 if (onMismatch) { onMismatch(file, match); return }
115
116 setPendingFile(file)
117 setPendingMatch(match)
118 }, [pendingIntent, onMatch, onMismatch])
119
120 const handleUploadThis = useCallback(() => {
121 if (pendingFile && pendingMatch) onMatch(pendingFile, pendingMatch)
122 setPendingFile(null)
123 setPendingMatch(null)
124 }, [pendingFile, pendingMatch, onMatch])
125
126 const handlePickAgain = useCallback(() => {
127 setPendingFile(null)
128 setPendingMatch(null)
129 setTimeout(() => inputRef.current?.click(), 50)
130 }, [])
131
132 const handleCancel = useCallback(() => {
133 setPendingFile(null)
134 setPendingMatch(null)
135 onCancel?.()
136 }, [onCancel])
137
138 const acceptType = accept ?? pendingIntent?.fileType ?? undefined
139
140 return (
141 <div style={{ display: 'contents' }} className={className}>
142 <input ref={inputRef} type="file" accept={acceptType} style={{ display: 'none' }} onChange={handleChange} aria-hidden="true" />
143
144 {children ? children({ open }) : (
145 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={open} type="button">Re-select File</button>
146 )}
147
148 {pendingFile && pendingMatch && pendingMatch !== 'exact' && pendingIntent && (
149 <MatchDialog
150 match={pendingMatch as 'similar' | 'different'}
151 selectedFile={pendingFile}
152 intent={pendingIntent}
153 onUploadThis={handleUploadThis}
154 onPickAgain={handlePickAgain}
155 onCancel={pendingMatch === 'different' ? handleCancel : undefined}
156 />
157 )}
158 </div>
159 )
160}
161
162const styles: Record<string, React.CSSProperties> = {
163 overlay: { position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 13, 17, 0.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, backdropFilter: 'blur(4px)' },
164 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)' },
165 iconWrap: { alignSelf: 'flex-start', padding: '8px 10px', borderRadius: 8 },
166 dialogTitle: { margin: 0, fontSize: 16, fontWeight: 700, color: COLORS.text, lineHeight: 1.3 },
167 dialogBody: { margin: 0, color: COLORS.muted, lineHeight: 1.6 },
168 dialogActions: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 },
169 btn: { padding: '8px 14px', borderRadius: 6, fontSize: 13, fontWeight: 600, cursor: 'pointer', lineHeight: 1, border: '1px solid transparent', transition: 'opacity 0.15s' },
170 btnPrimary: { backgroundColor: COLORS.accent, color: '#fff', border: `1px solid ${COLORS.accent}` },
171 btnWarn: { backgroundColor: 'transparent', color: COLORS.amber, border: `1px solid ${COLORS.amber}` },
172 btnGhost: { backgroundColor: 'transparent', color: COLORS.muted, border: `1px solid ${COLORS.border}` },
173}
This is a copy-paste component, not a black box

Once copied, it is 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 are not.
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), but because dynamic
4// job tracking and handshake-quality queue config are what this
5// pattern is actually built on top of, and those ARE tier-gated.
6// Free tier can still queue a fire_and_forget upload intent — it just
7// won't have live job progress to show while it uploads.
Resilient Metadata Preservations

By isolating pending upload metadata in IndexedDB under the user's scoped partition, form state and file descriptions stay tied to active form drafts across reboots even while waiting for byte re-selection.

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 the master documentation roadmap.

Powered by Flux