Upload files in Angular with progress and a local server
Send a selected file as FormData through Angular’s HttpClient, and show success only when the
server accepts it. This walkthrough builds a single-file uploader with progress, visible errors,
and a runnable Node.js receiver. The receiver checks the request and returns a checksum; it does
not save the file.
Required setup
Use Node.js 26.8.2, Corepack with Yarn 4.12.0, and a modern browser. The example pins Angular 22.1.7, Angular CLI/build 22.1.8, and TypeScript 6.0.3. Angular 22 is a supported release; its compatibility table explains the Node and TypeScript requirements. This is a standalone browser app without server-side rendering. The example was tested on macOS with Chromium 145. The commands use a POSIX shell, such as Bash on macOS, Linux, or WSL.
Run this from a parent directory where you want a new angular-upload-demo folder. mkdir refuses
an existing destination, and each dependent command runs only if the previous one succeeds. Keep
the generated lockfile so later installs use the same dependency resolution.
(
mkdir angular-upload-demo &&
cd angular-upload-demo &&
NG_CLI_ANALYTICS=false corepack yarn@4.12.0 dlx @angular/cli@22.1.8 new upload-demo \
--directory . --standalone --routing=false --ssr=false --style=css \
--skip-tests --skip-git --skip-install --package-manager=yarn --defaults &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn@4.12.0 add --exact \
@angular/common@22.1.7 @angular/compiler@22.1.7 @angular/core@22.1.7 \
@angular/forms@22.1.7 @angular/platform-browser@22.1.7 @angular/router@22.1.7 \
rxjs@7.8.2 fastify@5.12.5 @fastify/multipart@10.1.1 &&
corepack yarn@4.12.0 add --dev --exact \
@angular/build@22.1.8 @angular/cli@22.1.8 @angular/compiler-cli@22.1.7 typescript@6.0.3
)
After that succeeds, open angular-upload-demo in your editor. All filenames below are relative
to that folder. Replace only the generated src/main.ts; create the other three files as named.
Use the browser’s multipart encoding
The native file input gives you a File; FormData.append('file', file) puts its bytes in the
multipart field that our server expects. Leave Content-Type unset. The browser adds the
multipart boundary, as explained in MDN’s FormData guide.
No FormsModule or ngModel is needed for this input’s change event.
Upload progress requires Angular’s XHR backend. Configure provideHttpClient(withXhr()), then
request events with observe: 'events' and reportProgress: true. The default Fetch backend does
not report upload progress. Angular documents both the
XHR configuration and the
request event sequence.
A progress event describes transmission of the request body, including multipart framing.
Even 100% transferred does not mean that the server accepted the file.
Add the upload component
Replace src/main.ts with this complete entry point. Signals update the view when HTTP events
arrive. While a request is pending, both controls are disabled and the handlers guard against
repeated calls. A failed request retains the selected file for a manual retry; success clears the
native input so selecting the same file again works.
import { HttpClient, HttpErrorResponse, HttpEventType, provideHttpClient, withXhr } from '@angular/common/http'
import { Component, DestroyRef, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { bootstrapApplication } from '@angular/platform-browser'
import { finalize } from 'rxjs'
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './upload.html',
})
class UploadDemo {
readonly selected = signal<File | null>(null)
readonly busy = signal(false)
readonly progress = signal<number | null>(null)
readonly message = signal('Choose a file to upload.')
readonly #http = inject(HttpClient)
readonly #destroyRef = inject(DestroyRef)
select(event: Event): void {
if (this.busy()) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
const file = input.files?.[0] ?? null
this.progress.set(null)
this.selected.set(null)
if (file === null) {
this.message.set('Choose a file to upload.')
return
}
if (file.size > 1024 * 1024) {
input.value = ''
this.message.set('Choose a file of 1 MiB or smaller.')
return
}
this.selected.set(file)
this.message.set(`Ready to upload “${file.name}”.`)
}
upload(input: HTMLInputElement): void {
const file = this.selected()
if (file === null || this.busy()) return
this.busy.set(true)
this.progress.set(null)
this.message.set(`Sending “${file.name}”…`)
const body = new FormData()
body.append('file', file)
this.#http.post('/api/upload', body, {
observe: 'events',
reportProgress: true,
timeout: 30_000,
}).pipe(
takeUntilDestroyed(this.#destroyRef),
finalize(() => this.busy.set(false)),
).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress) {
const total = event.total
this.progress.set(total != null && total > 0
? Math.floor(100 * event.loaded / total)
: null)
if (total != null && total > 0 && event.loaded >= total) {
this.message.set('Transfer complete. Waiting for server acceptance…')
}
} else if (event.type === HttpEventType.Response) {
this.selected.set(null)
input.value = ''
this.message.set(`Server accepted “${file.name}”. This demo did not save it.`)
}
},
error: (error: unknown) => {
this.progress.set(null)
this.message.set(error instanceof HttpErrorResponse && error.status !== 0
? `Upload failed (HTTP ${error.status}). Try again or choose another file.`
: 'Network error or timeout. The server may have received the file; check before retrying.')
},
})
}
}
bootstrapApplication(UploadDemo, {
providers: [provideHttpClient(withXhr())],
}).catch(console.error)
Label the controls and feedback
Create src/upload.html. The native input and button support keyboard navigation. The status
region announces feedback without moving focus. An unknown total leaves the progress bar
indeterminate; a small local upload may finish too quickly to show intermediate percentages.
<main>
<h1>Upload a file</h1>
<p id="file-hint">Up to 1 MiB. The server rejects empty files and does not save uploads.</p>
<label for="upload-file">Choose a file</label>
<input #fileInput id="upload-file" type="file" aria-describedby="file-hint"
[disabled]="busy()" (change)="select($event)" />
<button type="button" [disabled]="selected() === null || busy()"
(click)="upload(fileInput)">Upload</button>
@if (busy()) {
<p>
<progress aria-label="Request upload progress" max="100" [attr.value]="progress()"></progress>
@if (progress() !== null) {
<span>{{ progress() }}% transferred</span>
}
</p>
}
<p role="status" aria-atomic="true">{{ message() }}</p>
</main>
Run a receiver that checks the upload
Create server.ts at the project root. It accepts POST /api/upload with exactly one multipart
file field named file, rejects empty files with HTTP 422, and limits files to 1 MiB. The
multipart plugin enforces size and part-count limits;
toBuffer() consumes the file before the response is produced.
The receiver returns the received filename, field name, byte count, and SHA-256 checksum. It buffers the file in memory and discards it after the request. It has no storage path and therefore never overwrites an existing file. This local demo accepts any file type and binds only to the loopback address.
import { createHash } from 'node:crypto'
import multipart from '@fastify/multipart'
import Fastify from 'fastify'
const server = Fastify()
await server.register(multipart, {
limits: { files: 1, fields: 0, parts: 1, fileSize: 1024 * 1024 },
})
server.setErrorHandler((error, _request, reply) => {
// A part-count limit can interrupt toBuffer() before the parser's own error surfaces.
if (error instanceof Error && 'code' in error && error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
return reply.code(400).send({ error: 'Incomplete multipart upload.' })
}
const status = error instanceof Error && 'statusCode' in error
&& typeof error.statusCode === 'number'
&& error.statusCode >= 400 && error.statusCode <= 599
? error.statusCode : 500
reply.code(status).send({ error: 'Upload failed.' })
})
server.post('/api/upload', async (request, reply) => {
let receipt: { field: string; filename: string; bytes: number; sha256: string } | null = null
for await (const part of request.parts()) {
if (part.type !== 'file') {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
const bytes = await part.toBuffer()
if (part.fieldname !== 'file') {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
if (bytes.length === 0) {
return reply.code(422).send({ error: 'Empty files are not accepted.' })
}
receipt = {
field: part.fieldname,
filename: part.filename,
bytes: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
}
}
if (receipt === null) {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
return receipt
})
const address = await server.listen({ host: '127.0.0.1', port: Number(process.env.PORT ?? '3000') })
console.log(`Receiver listening at ${address}`)
Create proxy.json at the project root so the Angular development server forwards /api requests
to Node. The browser uses its own origin, so this example does not need CORS configuration.
{
"/api/**": {
"target": "http://127.0.0.1:3000"
}
}
From the parent directory used for setup, start the receiver in one terminal:
cd angular-upload-demo && node server.ts
In a second terminal, also starting in that parent directory, build and serve the app:
cd angular-upload-demo &&
corepack yarn@4.12.0 ng build &&
NG_CLI_ANALYTICS=false corepack yarn@4.12.0 ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.json
Open http://127.0.0.1:4200. If either port is occupied, use another free port: set PORT when
starting Node and update the proxy target to match; change Angular’s --port as needed. Stop both
servers with Ctrl+C when you are finished. Rebuilding replaces this generated app’s dist/
output; uploaded files are never written there.
Check success and failure separately
Select a nonempty file below 1 MiB and activate Upload. The final message should say
“Server accepted” and explain that the demo did not save it. In the browser’s Network panel,
inspect the /api/upload response: field should be file, and bytes and sha256 describe the
received file contents, excluding multipart framing. Select the same file again to send another
request.
Try an empty file to see the HTTP 422 message. A file over 1 MiB should be rejected in the UI before sending; the server also enforces its own limit for callers that bypass the UI. While an upload is pending, the file picker and Upload button stay disabled, including after transmission reaches 100%. A delayed or rejected server response must never become an accepted-upload message.
To exercise a connection failure, let the page load, switch the browser’s Network panel to offline, and then upload. Restore connectivity before retrying. Stopping only the Node receiver may instead produce an HTTP error from the development proxy. A network error or timeout cannot tell you whether the server processed the request before the connection failed; this example does not retry automatically.
The size check is a convenience for the user, not a security boundary. If your application needs
JPEG, PNG, or PDF uploads, the input’s accept attribute can guide selection, but both it and
File.type are client-controlled hints. Validate actual content on the receiving server. MDN
explains why file-picker restrictions do not validate uploads.
A deployed receiver also needs your application’s authentication, authorization, request-forgery
protection, and storage policy; the local receiver does not provide those.
Add resumability when you need it
This multipart request starts over after a failed transfer. If you need to continue interrupted uploads, see resumable file uploads in Angular with tus-js-client. For a ready-made file picker and upload UI, the official Uppy Angular guide documents its components and compatible versions. Pair that UI with an uploader plugin and a receiver that speaks the same protocol.
