Skip to main content
Search...

chapter 5: pause, resume & retry

Control upload lifecycle with pause, resume, cancel, and automatic retry.

Goal

PhotoDuck users upload large photos over unreliable connections. A 50 MB file on a spotty train Wi-Fi needs to pause when signal drops and resume when it comes back. You will add pause, resume, cancel controls and configure automatic retry with exponential backoff.

Loading diagram...

Dispatch Pause and Resume Commands

Pause an active upload

The store accepts commands through dispatch(). To pause an upload that is currently in the uploading phase, dispatch a pause command with its localId:

src/components/UploadControls.tsx
import { useUploader } from '@gentleduck/upload/react'
 
function UploadControls({ localId }: { localId: string }) {
  const { store } = useUploader()
 
  const handlePause = () => {
    store.dispatch({ type: 'pause', localId })
  }
 
  return <button onClick={handlePause}>Pause</button>
}
src/components/UploadControls.tsx
import { useUploader } from '@gentleduck/upload/react'
 
function UploadControls({ localId }: { localId: string }) {
  const { store } = useUploader()
 
  const handlePause = () => {
    store.dispatch({ type: 'pause', localId })
  }
 
  return <button onClick={handlePause}>Pause</button>
}

When you dispatch pause, the engine sets the inflight upload's abort mode to 'pause' and aborts the underlying AbortController. The strategy receives the abort signal, stops transferring bytes, and emits an internal paused event with the current cursor position. The reducer transitions the item from uploading to paused.

If the item is in the queued phase (waiting for a concurrency slot), pause reverts it back to ready without touching the network.

Resume a paused upload

src/components/UploadControls.tsx
const handleResume = () => {
  store.dispatch({ type: 'resume', localId })
}
src/components/UploadControls.tsx
const handleResume = () => {
  store.dispatch({ type: 'resume', localId })
}

Resume moves the item from paused back to queued. The scheduler picks it up when a concurrency slot opens. The strategy reads the stored cursor to continue from the last byte offset -- no data is re-uploaded.

Resume requires the item to have a file reference. After a page refresh, File objects are lost (Chapter 6 covers rebinding). If item.file is undefined, resume is a no-op.

Cancel uploads and clean up

src/components/UploadControls.tsx
const handleCancel = () => {
  store.dispatch({ type: 'cancel', localId })
}
 
// Remove the item from state entirely
const handleRemove = () => {
  store.dispatch({ type: 'remove', localId })
}
src/components/UploadControls.tsx
const handleCancel = () => {
  store.dispatch({ type: 'cancel', localId })
}
 
// Remove the item from state entirely
const handleRemove = () => {
  store.dispatch({ type: 'remove', localId })
}

Cancel transitions any non-terminal item to the canceled phase. If the item is actively uploading, the engine aborts the network request with mode 'cancel'. Unlike pause, cancel is final -- the cursor and intent are preserved on the item for debugging, but the item cannot be resumed.

Use remove to delete the item from state completely. This is useful for cleaning up the UI after a cancel or completion.

Configure automatic retry with exponential backoff

src/lib/upload-client.ts
import { createUploadStore } from '@gentleduck/upload'
 
const store = createUploadStore({
  api: photoDuckApi,
  strategies: photoDuckStrategies,
  config: {
    maxAttempts: 5,
    retryPolicy: ({ phase, attempt, error }) => {
      // Don't retry auth errors
      if (error.code === 'auth') return { retryable: false }
 
      // Don't retry validation failures
      if (error.code === 'validation_failed') return { retryable: false }
 
      // Exponential backoff: 1s, 2s, 4s, 8s, 16s
      const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000)
 
      return { retryable: true, delayMs }
    },
  },
})
src/lib/upload-client.ts
import { createUploadStore } from '@gentleduck/upload'
 
const store = createUploadStore({
  api: photoDuckApi,
  strategies: photoDuckStrategies,
  config: {
    maxAttempts: 5,
    retryPolicy: ({ phase, attempt, error }) => {
      // Don't retry auth errors
      if (error.code === 'auth') return { retryable: false }
 
      // Don't retry validation failures
      if (error.code === 'validation_failed') return { retryable: false }
 
      // Exponential backoff: 1s, 2s, 4s, 8s, 16s
      const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000)
 
      return { retryable: true, delayMs }
    },
  },
})

The retryPolicy function is called whenever a phase fails. It receives the current phase ('intent', 'upload', or 'complete'), the attempt number, and the UploadError. Return { retryable: true, delayMs } to schedule a retry after the given delay, or { retryable: false } to mark the item as permanently failed.

maxAttempts is the global cap. Even if retryPolicy says retryable, the engine stops after this many attempts. The default is 3.

Handle errors and show retry UI

src/components/UploadItem.tsx
import { useUploader } from '@gentleduck/upload/react'
import type { UploadItem } from '@gentleduck/upload'
 
function UploadItemRow({ item }: { item: UploadItem }) {
  const { store } = useUploader()
 
  if (item.phase === 'error') {
    return (
      <div className="flex items-center gap-2">
        <span className="text-red-500">
          {item.error.message}
        </span>
 
        {item.retryable && (
          <button
            onClick={() => store.dispatch({ type: 'retry', localId: item.localId })}
          >
            Retry (attempt {item.attempt})
          </button>
        )}
 
        <button
          onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}
        >
          Dismiss
        </button>
      </div>
    )
  }
 
  return (
    <div className="flex items-center gap-2">
      {item.phase === 'uploading' && (
        <>
          <progress value={item.progress.pct} max={100} />
          <button onClick={() => store.dispatch({ type: 'pause', localId: item.localId })}>
            Pause
          </button>
        </>
      )}
 
      {item.phase === 'paused' && (
        <button onClick={() => store.dispatch({ type: 'resume', localId: item.localId })}>
          Resume
        </button>
      )}
 
      <button onClick={() => store.dispatch({ type: 'cancel', localId: item.localId })}>
        Cancel
      </button>
    </div>
  )
}
src/components/UploadItem.tsx
import { useUploader } from '@gentleduck/upload/react'
import type { UploadItem } from '@gentleduck/upload'
 
function UploadItemRow({ item }: { item: UploadItem }) {
  const { store } = useUploader()
 
  if (item.phase === 'error') {
    return (
      <div className="flex items-center gap-2">
        <span className="text-red-500">
          {item.error.message}
        </span>
 
        {item.retryable && (
          <button
            onClick={() => store.dispatch({ type: 'retry', localId: item.localId })}
          >
            Retry (attempt {item.attempt})
          </button>
        )}
 
        <button
          onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}
        >
          Dismiss
        </button>
      </div>
    )
  }
 
  return (
    <div className="flex items-center gap-2">
      {item.phase === 'uploading' && (
        <>
          <progress value={item.progress.pct} max={100} />
          <button onClick={() => store.dispatch({ type: 'pause', localId: item.localId })}>
            Pause
          </button>
        </>
      )}
 
      {item.phase === 'paused' && (
        <button onClick={() => store.dispatch({ type: 'resume', localId: item.localId })}>
          Resume
        </button>
      )}
 
      <button onClick={() => store.dispatch({ type: 'cancel', localId: item.localId })}>
        Cancel
      </button>
    </div>
  )
}

The retry command is only accepted when the item is in the error phase and retryable is true. Retry transitions the item differently depending on its state:

  • If the item has no intent yet (intent creation failed): moves to creating_intent
  • If the item has an intent but upload failed: moves to ready to re-queue
  • If the item was in the completing phase (100% uploaded, finalization failed): moves back to completing to retry finalization only

Batch Operations

For bulk control, dispatch pauseAll, startAll, or cancelAll. These iterate over every item in state and apply the individual command. Pass an optional purpose to scope the operation:

src/components/BatchControls.tsx
function BatchControls() {
  const { store } = useUploader()
 
  return (
    <div className="flex gap-2">
      <button onClick={() => store.dispatch({ type: 'startAll' })}>
        Start All
      </button>
      <button onClick={() => store.dispatch({ type: 'pauseAll' })}>
        Pause All
      </button>
      <button onClick={() => store.dispatch({ type: 'cancelAll' })}>
        Cancel All
      </button>
 
      {/* Scope to a specific purpose */}
      <button onClick={() => store.dispatch({ type: 'startAll', purpose: 'photo' })}>
        Start All Photos
      </button>
    </div>
  )
}
src/components/BatchControls.tsx
function BatchControls() {
  const { store } = useUploader()
 
  return (
    <div className="flex gap-2">
      <button onClick={() => store.dispatch({ type: 'startAll' })}>
        Start All
      </button>
      <button onClick={() => store.dispatch({ type: 'pauseAll' })}>
        Pause All
      </button>
      <button onClick={() => store.dispatch({ type: 'cancelAll' })}>
        Cancel All
      </button>
 
      {/* Scope to a specific purpose */}
      <button onClick={() => store.dispatch({ type: 'startAll', purpose: 'photo' })}>
        Start All Photos
      </button>
    </div>
  )
}

The full set of commands accepted by the store:

CommandDescriptionValid From
startQueue a single item for uploadready
startAllQueue all ready itemsready (each)
pausePause an active or queued uploaduploading, queued
pauseAllPause all active uploadsuploading, queued (each)
resumeResume a paused uploadpaused (requires file)
cancelCancel an uploadany non-terminal
cancelAllCancel all non-terminal uploadsany non-terminal (each)
retryRetry a failed uploaderror (requires retryable)
removeRemove item from stateany
rebindRe-attach a File referencepaused (no file)

How the State Machine Transitions Between Phases

The upload engine uses a strict state machine. Each UploadItem has a phase field that acts as a discriminant. The reducer only allows transitions from valid source phases:

Loading diagram...

Key transitions to understand:

  1. Pause during upload: When you dispatch pause, the store sets inflightUpload.mode = 'pause' and calls controller.abort(). The strategy catches the abort, records the cursor position, and the engine emits { type: 'paused', cursor, pausedAt }. The reducer moves the item to paused with the cursor intact.

  2. Resume after pause: The resume command checks that item.file exists (File objects cannot survive persistence). If the file is present, the item goes to queued. The scheduler starts the strategy with the stored cursor so it resumes from the last byte.

  3. Error to retry: The retry command inspects the item to decide where to re-enter:

    • No intent: back to creating_intent (attempt incremented)
    • Has intent, progress < 100%: back to ready (re-queued for upload)
    • Has intent, progress = 100%: back to completing (only retry finalization)
  4. Automatic retry: When retryPolicy returns { retryable: true, delayMs: 1000 }, the engine schedules dispatch({ type: 'retry', localId }) after the delay. The UI sees the item in error phase briefly, then it transitions automatically. If you want manual-only retry, return { retryable: false } from the policy and let the user click retry.

Checkpoint

Full upload controls component with pause, resume, cancel, retry, and batch operations:

src/components/PhotoUploader.tsx
import { useUploader } from '@gentleduck/upload/react'
import type { UploadItem } from '@gentleduck/upload'
 
type Purpose = 'photo'
 
function PhotoUploader() {
  const { store } = useUploader()
  const snapshot = store.getSnapshot()
  const items = Array.from(snapshot.items.values())
 
  const activeCount = items.filter(
    (i) => i.phase === 'uploading' || i.phase === 'queued'
  ).length
 
  return (
    <div>
      <h2>PhotoDuck Uploads</h2>
 
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={(e) => {
          const files = Array.from(e.target.files ?? [])
          if (files.length > 0) {
            store.dispatch({ type: 'addFiles', files, purpose: 'photo' as Purpose })
          }
        }}
      />
 
      <div className="flex gap-2 my-4">
        <button onClick={() => store.dispatch({ type: 'startAll' })}>
          Start All
        </button>
        <button onClick={() => store.dispatch({ type: 'pauseAll' })}>
          Pause All
        </button>
        <button onClick={() => store.dispatch({ type: 'cancelAll' })}>
          Cancel All
        </button>
        <span>{activeCount} active</span>
      </div>
 
      <ul>
        {items.map((item) => (
          <li key={item.localId}>
            <UploadRow item={item} />
          </li>
        ))}
      </ul>
    </div>
  )
}
 
function UploadRow({ item }: { item: UploadItem }) {
  const { store } = useUploader()
 
  const phaseLabel: Record<string, string> = {
    validating: 'Validating...',
    creating_intent: 'Preparing...',
    ready: 'Ready',
    queued: 'Queued',
    uploading: 'Uploading',
    paused: 'Paused',
    completing: 'Finalizing...',
    completed: 'Done',
    error: 'Error',
    canceled: 'Canceled',
  }
 
  return (
    <div className="flex items-center gap-3 py-2">
      <span className="w-48 truncate">{item.fingerprint.name}</span>
      <span className="text-sm text-muted-foreground">{phaseLabel[item.phase]}</span>
 
      {item.phase === 'uploading' && (
        <progress value={item.progress.pct} max={100} className="flex-1" />
      )}
 
      {item.phase === 'paused' && 'progress' in item && (
        <span className="text-sm">{Math.round(item.progress.pct)}% paused</span>
      )}
 
      <div className="flex gap-1">
        {item.phase === 'ready' && (
          <button onClick={() => store.dispatch({ type: 'start', localId: item.localId })}>
            Start
          </button>
        )}
 
        {item.phase === 'uploading' && (
          <button onClick={() => store.dispatch({ type: 'pause', localId: item.localId })}>
            Pause
          </button>
        )}
 
        {item.phase === 'paused' && (
          <button onClick={() => store.dispatch({ type: 'resume', localId: item.localId })}>
            Resume
          </button>
        )}
 
        {item.phase === 'error' && item.retryable && (
          <button onClick={() => store.dispatch({ type: 'retry', localId: item.localId })}>
            Retry ({item.attempt})
          </button>
        )}
 
        {item.phase !== 'completed' && item.phase !== 'canceled' && (
          <button onClick={() => store.dispatch({ type: 'cancel', localId: item.localId })}>
            Cancel
          </button>
        )}
 
        {(item.phase === 'completed' || item.phase === 'canceled' || item.phase === 'error') && (
          <button onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}>
            Remove
          </button>
        )}
      </div>
    </div>
  )
}
src/components/PhotoUploader.tsx
import { useUploader } from '@gentleduck/upload/react'
import type { UploadItem } from '@gentleduck/upload'
 
type Purpose = 'photo'
 
function PhotoUploader() {
  const { store } = useUploader()
  const snapshot = store.getSnapshot()
  const items = Array.from(snapshot.items.values())
 
  const activeCount = items.filter(
    (i) => i.phase === 'uploading' || i.phase === 'queued'
  ).length
 
  return (
    <div>
      <h2>PhotoDuck Uploads</h2>
 
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={(e) => {
          const files = Array.from(e.target.files ?? [])
          if (files.length > 0) {
            store.dispatch({ type: 'addFiles', files, purpose: 'photo' as Purpose })
          }
        }}
      />
 
      <div className="flex gap-2 my-4">
        <button onClick={() => store.dispatch({ type: 'startAll' })}>
          Start All
        </button>
        <button onClick={() => store.dispatch({ type: 'pauseAll' })}>
          Pause All
        </button>
        <button onClick={() => store.dispatch({ type: 'cancelAll' })}>
          Cancel All
        </button>
        <span>{activeCount} active</span>
      </div>
 
      <ul>
        {items.map((item) => (
          <li key={item.localId}>
            <UploadRow item={item} />
          </li>
        ))}
      </ul>
    </div>
  )
}
 
function UploadRow({ item }: { item: UploadItem }) {
  const { store } = useUploader()
 
  const phaseLabel: Record<string, string> = {
    validating: 'Validating...',
    creating_intent: 'Preparing...',
    ready: 'Ready',
    queued: 'Queued',
    uploading: 'Uploading',
    paused: 'Paused',
    completing: 'Finalizing...',
    completed: 'Done',
    error: 'Error',
    canceled: 'Canceled',
  }
 
  return (
    <div className="flex items-center gap-3 py-2">
      <span className="w-48 truncate">{item.fingerprint.name}</span>
      <span className="text-sm text-muted-foreground">{phaseLabel[item.phase]}</span>
 
      {item.phase === 'uploading' && (
        <progress value={item.progress.pct} max={100} className="flex-1" />
      )}
 
      {item.phase === 'paused' && 'progress' in item && (
        <span className="text-sm">{Math.round(item.progress.pct)}% paused</span>
      )}
 
      <div className="flex gap-1">
        {item.phase === 'ready' && (
          <button onClick={() => store.dispatch({ type: 'start', localId: item.localId })}>
            Start
          </button>
        )}
 
        {item.phase === 'uploading' && (
          <button onClick={() => store.dispatch({ type: 'pause', localId: item.localId })}>
            Pause
          </button>
        )}
 
        {item.phase === 'paused' && (
          <button onClick={() => store.dispatch({ type: 'resume', localId: item.localId })}>
            Resume
          </button>
        )}
 
        {item.phase === 'error' && item.retryable && (
          <button onClick={() => store.dispatch({ type: 'retry', localId: item.localId })}>
            Retry ({item.attempt})
          </button>
        )}
 
        {item.phase !== 'completed' && item.phase !== 'canceled' && (
          <button onClick={() => store.dispatch({ type: 'cancel', localId: item.localId })}>
            Cancel
          </button>
        )}
 
        {(item.phase === 'completed' || item.phase === 'canceled' || item.phase === 'error') && (
          <button onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}>
            Remove
          </button>
        )}
      </div>
    </div>
  )
}

Chapter 5 FAQ

What happens if I pause a queued item?

If the item is in the queued phase (waiting for a concurrency slot but not yet uploading), the reducer moves it back to ready. No network request is aborted because none was started. You can start it again later.

Why does resume require a file reference?

Browser File objects cannot be serialized. After a page refresh, paused items restored from persistence have file: undefined. The resume command checks for item.file and is a no-op if it is missing. Use the rebind command to re-attach the file first: store.dispatch({ type: 'rebind', localId, file }). The engine verifies the fingerprint matches before accepting the rebind. See Chapter 6 for the full persistence and rebind workflow.

How does retry know where to re-enter the pipeline?

The reducer inspects the failed item's state. If item.intent is undefined, the failure was during intent creation, so retry goes to creating_intent. If an intent exists and progress is at 100%, the failure was during completion, so retry goes to completing. Otherwise the upload itself failed, so retry goes to ready to be re-queued. This means retry never re-uploads bytes that were already confirmed.

Can I have automatic retry for some errors and manual for others?

Yes. Your retryPolicy function decides per-error. Return { retryable: true, delayMs } for transient errors (network, timeout, rate limit) and { retryable: false } for permanent errors (auth, validation). When you return retryable false, the item stays in the error phase with retryable: false, and the retry button is hidden. The user can still remove or cancel it.

What is a cursor and how does it enable resumable uploads?

A cursor is a strategy-specific checkpoint that records how far an upload has progressed. For a multipart strategy, the cursor tracks which parts have been uploaded and their ETags. For a TUS strategy, the cursor tracks the byte offset. When the strategy resumes, it reads the cursor and skips already-uploaded segments. The cursor is updated via cursor.updated internal events during the upload and is persisted alongside the item.

Does cancel clean up server-side resources?

The cancel command only affects client-side state. It aborts the in-flight request and transitions the item to canceled. Server-side cleanup (like aborting a multipart upload) depends on your backend. If your UploadApi has multipart.abort(), you can call it in an event listener: store.on('upload.canceled', ({ localId }) => { ... }). Most cloud providers also have lifecycle policies that auto-clean incomplete multipart uploads after a configurable period.


Next: Chapter 6: Persistence & Offline