Resumable file uploads in Angular
Use tus-js-client with a tus server to resume an Angular file upload from the bytes the server
already has. This example builds a standalone Angular form and a local disk-backed receiver. You
will be able to pause a transfer, reload the page, select the same file, and finish uploading it.
Why resumable uploads?
The tus protocol uses a POST to create an upload URL,
PATCH requests to send bytes, and a HEAD request to discover the saved Upload-Offset when
resuming. The server’s offset decides where to continue. A browser progress bar alone cannot tell
you which bytes survived an interrupted connection.
The browser stores the upload URL, not a copy of the file. After a reload, the user must select the original, unchanged file again in the same browser profile and page origin. Clearing site storage, changing the file, or losing the server’s stored upload can mean starting over.
Setting up your Angular project
Use Node.js 24.15.0 and Yarn 4.12.0 for this walkthrough. The package versions below were built and exercised in Chromium 145 on Linux. Angular has its own Node.js and TypeScript compatibility requirements; these pins describe the tested setup.
From a working directory where you want the example to live, create a new project directory. These Bash commands refuse an existing directory and an enclosing Yarn Plug’n’Play project, whose loader can affect Angular’s build. They leave your shell in its original location:
(
node --input-type=module <<'NODE' &&
import { existsSync, realpathSync } from 'node:fs'
import { dirname, join } from 'node:path'
let directory = realpathSync('.')
while (true) {
if (existsSync(join(directory, '.pnp.cjs')) || existsSync(join(directory, '.pnp.js'))) {
console.error('Create this demo outside the enclosing Yarn Plug’n’Play project.')
process.exit(1)
}
const parent = dirname(directory)
if (parent === directory) break
directory = parent
}
NODE
mkdir resumable-upload-demo &&
cd resumable-upload-demo &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock
)
Save each file below inside resumable-upload-demo. The local lockfile and Yarn configuration give
this example its own dependency installation. package.json pins the direct dependencies and the
browser build target:
{
"name": "resumable-upload-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "ng build",
"start": "ng serve --host 127.0.0.1 --port 4200",
"server": "node server.ts"
},
"browserslist": ["Chrome 145"],
"dependencies": {
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/common": "22.2.0",
"@angular/compiler": "22.2.0",
"@angular/compiler-cli": "22.2.0",
"@angular/core": "22.2.0",
"@angular/platform-browser": "22.2.0",
"@tus/file-store": "2.1.1",
"@tus/server": "2.4.5",
"@types/node": "24.19.0",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"tus-js-client": "4.3.1",
"typescript": "6.0.3"
}
}
Save angular.json to connect the build and development server to main.ts:
{
"version": 1,
"cli": { "analytics": false, "cache": { "enabled": false } },
"projects": {
"demo": {
"projectType": "application",
"root": ".",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "main.ts",
"tsConfig": "tsconfig.json",
"index": "index.html",
"outputPath": "dist",
"styles": [],
"assets": []
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": { "buildTarget": "demo:build" }
}
}
}
}
}
Save tsconfig.json. The Node types satisfy the client package’s shared declarations; they do not
add Node APIs to the browser:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"types": ["node"],
"lib": ["ES2022", "DOM"],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["main.ts"]
}
Save index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
<title>Resumable Angular upload</title>
</head>
<body><app-upload></app-upload></body>
</html>
Add the local tus server
Save server.ts. The Node tus server
handles the protocol and CORS headers; its
FileStore keeps both uploaded
bytes and metadata in uploads/. Restarting this process from the same directory retains them.
This local receiver has a 100 MiB file limit and no authentication. Keep it bound to loopback.
import { FileStore } from '@tus/file-store'
import { Server } from '@tus/server'
const server = new Server({
path: '/files',
datastore: new FileStore({ directory: './uploads' }),
maxSize: 100 * 1024 * 1024,
allowedOrigins: ['http://127.0.0.1:4200'],
})
server.listen({ host: '127.0.0.1', port: 1080 })
.once('listening', () => {
console.log('Tus receiver ready at http://127.0.0.1:1080/files')
})
.once('error', () => {
console.error('Cannot start the receiver. Check whether port 1080 is already in use.')
process.exitCode = 1
})
Creating an upload service
Save upload.service.ts. The service claims the busy state before looking up saved URLs, ignores
callbacks from a stopped upload, and waits for abort() before allowing another start. Its signals
notify Angular when the client’s asynchronous callbacks update the UI, including with
zoneless change detection.
import { Injectable, signal, type OnDestroy } from '@angular/core'
import { DetailedError, Upload } from 'tus-js-client'
@Injectable()
export class UploadService implements OnDestroy {
readonly busy = signal(false)
readonly pausing = signal(false)
readonly percentage = signal(0)
readonly message = signal('Choose a file, then select Upload.')
readonly uploadUrl = signal<string | null>(null)
#upload: Upload | null = null
#ignoreCallbacks = false
async start(file: File): Promise<void> {
if (this.busy()) return
if (file.size > 100 * 1024 * 1024) {
this.message.set('Choose a file no larger than 100 MiB.')
return
}
this.busy.set(true)
this.#ignoreCallbacks = false
this.percentage.set(0)
this.uploadUrl.set(null)
this.message.set('Uploading…')
const isCurrent = (): boolean => this.#upload === upload && !this.#ignoreCallbacks
const upload = new Upload(file, {
endpoint: 'http://127.0.0.1:1080/files',
chunkSize: 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000],
removeFingerprintOnSuccess: true,
metadata: { filename: file.name },
onUploadUrlAvailable: () => {
if (isCurrent()) this.uploadUrl.set(upload.url)
},
onProgress: (sent, total) => {
if (isCurrent()) this.percentage.set(total > 0 ? (sent / total) * 100 : 0)
},
onSuccess: () => {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
this.percentage.set(100)
this.message.set('Upload complete.')
},
onError: (error) => {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
const status = error instanceof DetailedError ? error.originalResponse?.getStatus() : null
this.message.set(status === 401 || status === 403
? 'Upload denied. Check authorization before retrying.'
: 'Upload failed. Check the receiver and connection, then select Upload to retry.')
},
})
this.#upload = upload
try {
const previous = await upload.findPreviousUploads()
if (!isCurrent()) return
if (previous[0]) upload.resumeFromPreviousUpload(previous[0])
upload.start()
} catch {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
this.message.set('Cannot prepare the upload. Check browser storage, then retry.')
}
}
async pause(): Promise<void> {
const upload = this.#upload
if (!upload || this.pausing()) return
this.#ignoreCallbacks = true
this.pausing.set(true)
try {
await upload.abort()
this.#upload = null
this.busy.set(false)
this.message.set('Paused. Select Upload to resume.')
} catch {
this.message.set('Could not pause. Select Pause to try again.')
} finally {
this.pausing.set(false)
}
}
ngOnDestroy(): void {
const upload = this.#upload
this.#upload = null
void upload?.abort().catch(() => {
console.error('Unable to pause the upload during cleanup.')
})
}
}
The client API defines abort() as a
pause; abort(true) requests deletion instead. This example discards the stopped client instance
and looks up its URL on the next start, so both button-based resume and reload recovery require
browser URL storage. It selects the first stored match, suitable for this single-user demo.
The 1 MiB chunks make individual requests easy to inspect. They are not required for resumability; the client’s default is an unlimited chunk size. Its retry policy bounds consecutive retries and resets the budget when progress is made. Once retries stop, Upload lets the user try again.
Building the upload component
Save main.ts. This is both the standalone component and the application entry point, so there is
no root template or module left to wire up. The service instance belongs to the component and is
cleaned up when Angular destroys it.
import { DecimalPipe } from '@angular/common'
import { Component, inject, signal } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'
import { UploadService } from './upload.service.ts'
@Component({
selector: 'app-upload',
standalone: true,
imports: [DecimalPipe],
providers: [UploadService],
template: `
<main>
<h1>Resumable upload</h1>
<label for="file">Choose file</label>
<input id="file" type="file" [disabled]="uploader.busy()" (change)="select($event)" />
<button type="button" (click)="start()" [disabled]="!file() || uploader.busy()">
Upload
</button>
<button type="button" (click)="uploader.pause()"
[disabled]="!uploader.busy() || uploader.pausing()">Pause</button>
<p>Sent: {{ uploader.percentage() | number: '1.0-0' }}%</p>
<progress max="100" [value]="uploader.percentage()" aria-label="Upload progress"></progress>
<p role="status">{{ uploader.message() }}</p>
@if (uploader.uploadUrl(); as url) {
<p>Upload URL: <code>{{ url }}</code></p>
}
</main>
`,
})
export class UploadComponent {
readonly uploader = inject(UploadService)
readonly file = signal<File | null>(null)
select(event: Event): void {
if (this.uploader.busy()) return
if (event.target instanceof HTMLInputElement) {
this.file.set(event.target.files?.[0] ?? null)
}
}
start(): void {
const file = this.file()
if (file) void this.uploader.start(file)
}
}
bootstrapApplication(UploadComponent).catch(() => {
console.error('Unable to start the upload application.')
})
Run it and verify a resumed transfer
With all seven files saved, install and build from the parent directory:
(cd resumable-upload-demo && yarn install && yarn build)
After a successful build, start the receiver in one terminal and the Angular development server in another, both from that same parent directory:
(cd resumable-upload-demo && yarn server)
(cd resumable-upload-demo && yarn start)
Open http://127.0.0.1:4200. Select a file smaller than 100 MiB through
Choose file, then select
Upload. Use a file of several MiB and browser network throttling
to give yourself time to select Pause. Wait for
Paused. Select Upload to resume. before continuing.
Inspect the browser’s network requests. After at least one successful PATCH, reload the page,
select that same unchanged file, and select Upload again. You
should see a HEAD to the previous upload URL, followed by a PATCH whose request Upload-Offset
matches the offset returned by HEAD. That offset should be greater than zero. A new POST means
the client created a new upload instead of resuming the old one.
Wait for Upload complete.. The last segment of the displayed
upload URL identifies the file in resumable-upload-demo/uploads/; a neighboring .json file holds
metadata. Compare that file’s bytes or SHA-256 digest with your original. The original filename is
metadata, not a disk path. Uploading again after success creates a separate resource because the
client removes its completed URL record. Existing uploads remain on disk.
The percentage shows bytes sent by the browser and can move backward after recovery. Completion comes from the tus success callback, not from reaching 100% on the progress bar. Neither means that a production application has validated, scanned, or published the file.
CORS configuration
Use 127.0.0.1 consistently: localhost is a different origin. The receiver allows the Angular
origin on port 4200 and supplies the tus request and exposed response headers, including Location
and Upload-Offset. If a port is occupied, choose another and update the matching URL in
server.ts, upload.service.ts, or the start script. CORS errors often appear to the client as
network failures; inspect the preflight response as well as the upload request.
CORS is not authentication. This receiver is for local testing and does not authorize users.
tus-js-client uses its own XMLHttpRequest transport, so Angular HttpInterceptor does not attach
credentials to these requests. For a protected integration, use the client’s documented headers
or onBeforeRequest option, allow any added headers on the server, and authorize every upload
operation. Keep credentials out of this example and only send them to trusted upload URLs.
Best practices and considerations
The client’s browser fingerprint uses file metadata and the endpoint, not a content hash. Reselect the unchanged original. A multi-account application needs account-scoped URL storage and a deliberate policy for multiple matches; selecting the first match is not an ownership check.
If the saved URL returns 404 or 410, version 4.3.1 falls back to creating a new upload at the
configured endpoint. Missing server bytes cannot be recovered from browser storage. This local
server does not schedule expiration or remove abandoned files; stop both processes with Ctrl+C
when finished, and remove the demo’s uploads/ directory only when you no longer need its data.
For connection failures, restore the receiver or network and retry. For a denied request, resolve authorization first. The form keeps file selection and further starts disabled until an active upload finishes, fails, or finishes pausing. If pausing fails, retry Pause before starting anything else. The server enforces the size limit independently of the browser; production content validation and storage cleanup belong on the server too.
