Resumable file uploads in Angular with RxJS
Chunking limits how much data must be resent after a connection fails. This example implements a single-file, in-page upload with pause, resume, cancel, and whole-file progress. It retains the original article’s Angular/RxJS approach and updates the setup to Angular 22.1.6.
Why chunked and resumable uploads?
An interrupted request may have reached the server even when its response never reached the browser. Resume must therefore read the server’s committed offset. A local chunk counter alone cannot establish which bytes were stored.
This is a complete client integration, not an upload server. Before running it, provide this same-origin API behind your existing login and CSRF middleware:
| Request | Required behavior |
|---|---|
PUT /api/uploads/{id} with {size} | Reserve the UUID for the authenticated user, or return its existing state as {offset, complete}. Bind it permanently to that owner and size; reject conflicting reuse. |
PUT /api/uploads/{id}/bytes | Accept a raw body with Content-Range: bytes start-end/total. Under an upload lock, validate the exact length, total, quota, and expected offset; commit bytes before acknowledging with 204. |
POST /api/uploads/{id}/complete | Verify all bytes and content policy, then atomically publish once. Replays return the same success. |
DELETE /api/uploads/{id} | Idempotently cancel an unfinished upload under the same lock; reject cancellation of a published upload. |
Every operation must check ownership, session authentication and CSRF. Set a 100 MiB file limit, 1
MiB chunk limit, per-user storage/rate limits, and an expiration for abandoned sessions. Keep
partial data private. Refuse redirects and cross-origin access at the gateway. Angular sends the
X-XSRF-TOKEN header for relative mutation URLs when the server supplies the XSRF-TOKEN cookie;
the server must actually validate it. No Transloadit Auth Secret belongs in this browser code.
Setting up your Angular project
Use Node.js 24.15 or newer in the supported Node 24 line, and scaffold a standalone application:
corepack yarn dlx @angular/cli@22.1.8 new chunked-upload-app --standalone --ssr=false --style=css --package-manager=yarn
cd chunked-upload-app
corepack yarn add --exact @angular/common@22.1.6 @angular/compiler@22.1.6 @angular/core@22.1.6 @angular/platform-browser@22.1.6 rxjs@7.8.2
corepack yarn add --dev --exact @angular/compiler-cli@22.1.6 typescript@6.0.2
The examples use explicit .ts imports. Merge this option into the generated root tsconfig.json
so TypeScript rewrites those extensions when emitting JavaScript:
{
"compilerOptions": {
"rewriteRelativeImportExtensions": true
}
}
Use the following src/app/app.config.ts. Angular’s default Fetch backend does not emit upload
progress; this browser-only application explicitly selects XHR. See Angular’s
HTTP setup and
request lifecycle documentation.
import { provideHttpClient, withXhr } from '@angular/common/http'
import type { ApplicationConfig } from '@angular/core'
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withXhr())],
}
Serve the built application and API from the same origin. For local development, route /api to
your development backend through the CLI proxy; do not point it at production.
Implementing file slicing
The service below slices one chunk at a time. end is exclusive for File.slice, but inclusive in
the range header. A file of 2,097,155 bytes therefore ends with bytes 2097152-2097154/2097155, not
a total inferred from the short final chunk.
Creating an RxJS pipeline for chunk uploads
Place the complete service in src/app/file-upload.service.ts. concatMap keeps one request in
flight. Each resume establishes server state before sending more bytes; errors remain observable and
leave the selected file available for another resume attempt.
import { HttpClient, HttpEventType } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import {
BehaviorSubject,
EMPTY,
Subscription,
catchError,
concatMap,
concatWith,
defer,
finalize,
range,
tap,
} from 'rxjs'
interface UploadState {
progress: number
running: boolean
canceling: boolean
complete: boolean
message: string
}
@Injectable({ providedIn: 'root' })
export class FileUploadService {
readonly state = new BehaviorSubject<UploadState>({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Select a file.',
})
readonly #http = inject(HttpClient)
#request = new Subscription()
#file: File | null = null
#id = ''
#offset = 0
readonly #chunkSize = 1024 * 1024
select(file: File): void {
if (this.state.value.canceling) return
this.pause()
this.#file = null
if (file.size === 0 || file.size > 100 * 1024 * 1024) {
this.state.next({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Choose a file between 1 byte and 100 MiB.',
})
return
}
this.#file = file
this.#id = crypto.randomUUID()
this.#offset = 0
this.state.next({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Ready.',
})
this.resume()
}
resume(): void {
const file = this.#file
if (!file || this.state.value.running || this.state.value.canceling || this.state.value.complete) {
return
}
const url = `/api/uploads/${this.#id}`
this.state.next({ ...this.state.value, running: true, message: 'Uploading…' })
this.#request = this.#http
.put<unknown>(url, { size: file.size }, { timeout: 30_000 })
.pipe(
concatMap((status) => {
if (
typeof status !== 'object' ||
status === null ||
!('offset' in status) ||
typeof status.offset !== 'number' ||
!Number.isSafeInteger(status.offset) ||
status.offset < 0 ||
status.offset > file.size ||
!('complete' in status) ||
typeof status.complete !== 'boolean' ||
(status.complete && status.offset !== file.size)
) {
throw new Error('Invalid upload state')
}
this.#offset = status.offset
this.progress(file)
if (status.complete) {
this.finished()
return EMPTY
}
const offset = status.offset
return range(0, Math.ceil((file.size - offset) / this.#chunkSize)).pipe(
concatMap((index) =>
defer(() => {
const start = offset + index * this.#chunkSize
const end = Math.min(start + this.#chunkSize, file.size)
return this.#http
.put(`${url}/bytes`, file.slice(start, end), {
headers: { 'Content-Range': `bytes ${start}-${end - 1}/${file.size}` },
observe: 'events',
reportProgress: true,
timeout: 30_000,
})
.pipe(
tap((event) => {
if (event.type === HttpEventType.UploadProgress) {
this.progress(file, Math.min(event.loaded, end - start))
}
if (event.type === HttpEventType.Response) {
this.#offset = end
this.progress(file)
}
}),
)
}),
),
// Finalization runs once, including when the server already has every byte.
concatWith(
defer(() =>
this.#http
.post(`${url}/complete`, {}, { timeout: 30_000 })
.pipe(tap(() => this.finished())),
),
),
)
}),
finalize(() => this.state.next({ ...this.state.value, running: false })),
)
.subscribe({
error: () =>
this.state.next({ ...this.state.value, message: 'Upload interrupted. Resume to retry.' }),
})
}
pause(): void {
if (this.state.value.canceling) return
this.#request.unsubscribe()
if (this.#file && !this.state.value.complete) {
this.progress(this.#file)
this.state.next({ ...this.state.value, running: false, message: 'Paused.' })
}
}
cancel(): void {
if (this.state.value.canceling) return
this.pause()
if (!this.#file || this.state.value.complete) return
const url = `/api/uploads/${this.#id}`
this.#file = null
let confirmed = false
this.state.next({
progress: 0,
running: true,
canceling: true,
complete: false,
message: 'Canceling…',
})
this.#request = this.#http
.delete(url, { timeout: 30_000 })
.pipe(
tap(() => {
confirmed = true
}),
// Errors and interrupted requests both leave cleanup unconfirmed.
catchError(() => EMPTY),
// Publish one settled state so observers can safely select another file.
finalize(() =>
this.state.next({
...this.state.value,
running: false,
canceling: false,
message: confirmed ? 'Canceled.' : 'Cancellation unconfirmed. Check your uploads.',
}),
),
)
.subscribe()
}
destroy(): void {
this.pause()
// Destruction also interrupts cleanup; its finalizer reports the unconfirmed result.
this.#request.unsubscribe()
}
private progress(file: File, loaded = 0): void {
this.state.next({
...this.state.value,
progress: Math.min(99, Math.floor((100 * (this.#offset + loaded)) / file.size)),
})
}
private finished(): void {
this.state.next({
progress: 100,
running: false,
canceling: false,
complete: true,
message: 'Upload complete.',
})
}
}
Adding pause, resume, and cancellation
Unsubscribing aborts the active HTTP request. It does not undo bytes the server already accepted. Pause preserves the File and UUID in memory; resume reconciles the offset. Reloading loses this state, so this example does not resume across page reloads. Selecting another file abandons the old session, which the server’s expiration policy must remove. Cancel attempts server cleanup; a network failure or a race with publication requires checking the server’s upload list. While cleanup is pending, pause, resume, cancel, and file selection are disabled and ignored by the service. Destroying the component interrupts cleanup and reports cancellation as unconfirmed.
Tracking progress
Replace src/app/app.ts with this standalone root component. The built-in control-flow syntax needs
no NgIf; AsyncPipe is explicitly imported. Progress stays below 100 until publication is
acknowledged. A resume can move progress backward when unacknowledged bytes need resending.
import { AsyncPipe } from '@angular/common'
import { Component, inject, OnDestroy } from '@angular/core'
import { FileUploadService } from './file-upload.service.ts'
@Component({
selector: 'app-root',
standalone: true,
imports: [AsyncPipe],
template: `
@if (upload.state | async; as state) {
<label>File <input type="file" (change)="select($event)" [disabled]="state.running" /></label>
<progress aria-label="Upload progress" max="100" [value]="state.progress"></progress>
<p role="status">{{ state.message }}</p>
<button (click)="upload.pause()" [disabled]="!state.running || state.canceling">Pause</button>
<button (click)="upload.resume()" [disabled]="state.running || state.canceling || state.complete">Resume</button>
<button (click)="upload.cancel()" [disabled]="state.canceling || state.complete">Cancel</button>
}
`,
})
export class App implements OnDestroy {
readonly upload = inject(FileUploadService)
select(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
const file = event.target.files?.[0]
if (file) this.upload.select(file)
}
ngOnDestroy(): void {
this.upload.destroy()
}
}
Use this src/main.ts, retaining the generated index.html with its app-root element:
import { bootstrapApplication } from '@angular/platform-browser'
import { App } from './app/app.ts'
import { appConfig } from './app/app.config.ts'
bootstrapApplication(App, appConfig).catch(() => console.error('Application startup failed.'))
For a standardized resumable protocol instead of this application-specific contract, consider tus and Uppy. Neither client-side validation nor a progress bar replaces server authorization and durable commit semantics.
