Skip to main content
Published / updated

HttpClient and API Integration

Before you start

You need: RxJS (Article 08) and an API — Track 10, or a mock.

Time: about 50 minutes, plus the practice.

Learning objective

Call a REST API from Angular handling every outcome — loading, success, empty, error and cancellation — and diagnose a failed request from the Network tab.

Topics

  • Setting up HttpClient
  • GET, POST, PUT, DELETE
  • Query parameters and headers
  • Typed responses
  • Handling every status code
  • The four states
  • Debounce, cancellation and races
  • File upload and download
  • CORS
  • Diagnosing a failed call

Setup

// app.config.ts
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, errorInterceptor])
)
]
};

withFetch() uses the Fetch API rather than XMLHttpRequest — better streaming support and the direction Angular is moving.

Missing provideHttpClient() gives NullInjectorError: No provider for HttpClient, which is the first error everyone hits.

The verbs

@Injectable({ providedIn: 'root' })
export class StudentService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/students`;

search(query: StudentQuery): Observable<PagedResult<Student>> {
let params = new HttpParams()
.set('page', query.page)
.set('pageSize', query.pageSize);

if (query.term) {
params = params.set('term', query.term);
}

if (query.className) {
params = params.set('className', query.className);
}

return this.http.get<PagedResult<Student>>(this.baseUrl, { params });
}

getByPublicId(publicId: string): Observable<Student> {
return this.http.get<Student>(`${this.baseUrl}/${publicId}`);
}

create(request: StudentCreateRequest): Observable<Student> {
return this.http.post<Student>(this.baseUrl, request);
}

update(publicId: string, request: StudentUpdateRequest): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${publicId}`, request);
}

delete(publicId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${publicId}`);
}
}

Three things Angular does for you that fetch does not:

  • JSON is serialised and parsed automatically, and Content-Type: application/json is set on any request with an object body.
  • Non-2xx statuses produce an error, delivered to the error callback. No response.ok check.
  • The request is cancellable by unsubscribing.

HttpParams is immutable. params.set(...) returns a new instance:

// Wrong — the result is discarded
params.set('term', term);

// Right
params = params.set('term', term);

That silently drops the parameter, and the API returns unfiltered data.

HttpParams also URL-encodes values, so a search for 10th & A works. Building the query string by concatenation does not.

Headers

const headers = new HttpHeaders()
.set('X-Correlation-Id', crypto.randomUUID())
.set('Accept', 'application/json');

return this.http.get<Student[]>(this.baseUrl, { headers });

Do not set the Authorization header per call. An interceptor adds it once for every request — covered in the next article. Setting it by hand in twenty services means twenty places to change and one that gets forgotten.

HttpHeaders is immutable in the same way as HttpParams.

Typed responses

export interface Student {
publicId: string;
name: string;
rollNumber: string;
className: string;
section: string;
dateOfBirth: string; // ISO string, not Date
parentName: string;
parentPhone: string;
address: string | null;
}

export interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}

this.http.get<Student>() is a promise to the compiler, not a check. Nothing validates the response shape. A backend rename produces undefined at the point of use, far from the cause.

Dates arrive as strings. student.dateOfBirth.getFullYear() fails because it is a string, and TypeScript does not catch it if the interface says Date. Type it as string and convert where needed:

map(student => ({ ...student, dateOfBirth: new Date(student.dateOfBirth) }))

For data that matters, validate at the boundary — a runtime schema library derives the type from the schema, so there is one source of truth.

Handling status codes

import { HttpErrorResponse } from '@angular/common/http';

this.studentService.create(request).subscribe({
next: created => this.router.navigate(['/students', created.publicId]),
error: (err: HttpErrorResponse) => {
if (err.status === 0) {
this.error.set('Cannot reach the server. Check your connection.');
return;
}

if (err.status === 400 && err.error?.errors) {
this.applyFieldErrors(err.error.errors);
return;
}

if (err.status === 409) {
this.error.set('This roll number is already in use.');
return;
}

if (err.status >= 500) {
this.error.set('The server is not responding. Please try again shortly.');
return;
}

this.error.set('Could not save. Please try again.');
}
});
statusMeaningShow the user
0Network failure, CORS block, or cancelled"Cannot reach the server"
400Validation failedField-level messages from err.error.errors
401Not authenticatedRedirect to login
403Not permitted"You do not have permission"
404Not found"This student no longer exists"
409ConflictThe specific conflict
415Wrong content typeA bug in your code, not the user's problem
500+Server failure"Please try again shortly"

status === 0 is the one people misread. It is not a server response — it means the request never completed: no network, a CORS block, or the request was cancelled. Reporting it as "server error" sends people looking in the wrong place.

ASP.NET Core returns a ValidationProblemDetails body on 400:

{
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-8a3c…-01",
"errors": {
"RollNumber": ["Format: NCA-2024-0012"],
"ParentPhone": ["Enter a valid 10-digit mobile number."]
}
}
private applyFieldErrors(errors: Record<string, string[]>): void {
for (const [field, messages] of Object.entries(errors)) {
const key = field.charAt(0).toLowerCase() + field.slice(1);
const control = this.form.get(key);

if (control && messages[0]) {
control.setErrors({ server: messages[0] });
control.markAsTouched();
}
}
}

Mapping those to controls gives field-level messages rather than one generic banner. The PascalCase-to-camelCase conversion is what makes the keys match.

Show the traceId on a 500. The user quotes it in a support ticket and someone finds the exact request in the logs.

The four states

Every request has four outcomes. Most student projects handle one.

type ListState =
| { status: 'loading' }
| { status: 'success'; result: PagedResult<Student> }
| { status: 'empty'; term: string }
| { status: 'error'; message: string };

export class StudentListComponent {
private readonly studentService = inject(StudentService);

readonly state = signal<ListState>({ status: 'loading' });
readonly searchTerm = signal('');

load(term: string, page = 1): void {
this.state.set({ status: 'loading' });

this.studentService.search({ term, page, pageSize: 20 }).subscribe({
next: result => this.state.set(
result.items.length === 0
? { status: 'empty', term }
: { status: 'success', result }),
error: (err: HttpErrorResponse) => this.state.set({
status: 'error',
message: err.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.'
})
});
}
}
<p role="status" aria-live="polite" class="visually-hidden">{{ statusMessage() }}</p>

@switch (state().status) {
@case ('loading') {
<p class="muted">Loading students…</p>
}
@case ('empty') {
<p class="muted">No students match “{{ $any(state()).term }}”.</p>
}
@case ('error') {
<p class="error" role="alert">{{ $any(state()).message }}</p>
<button type="button" (click)="load(searchTerm())">Try again</button>
}
@case ('success') {
<app-student-table [result]="$any(state()).result" />
}
}

A discriminated union means the compiler enforces that every state is handled.

Distinguish empty from error. "No students match your search" and "Could not load students" are different messages with different next actions. A blank screen for both is indistinguishable from a bug.

aria-live="polite" announces state changes to a screen reader. A visual-only spinner tells them nothing.

Debounce, cancellation and races

export class StudentListComponent {
private readonly studentService = inject(StudentService);

readonly searchTerm = signal('');
readonly page = signal(1);

readonly result = toSignal(
combineLatest([
toObservable(this.searchTerm).pipe(debounceTime(300), distinctUntilChanged()),
toObservable(this.page)
]).pipe(
switchMap(([term, page]) =>
this.studentService.search({ term, page, pageSize: 20 }).pipe(
catchError(() => of({ items: [], totalCount: 0, page, pageSize: 20 }))
))
),
{ initialValue: { items: [], totalCount: 0, page: 1, pageSize: 20 } });
}

Three properties, all necessary:

  • debounceTime(300) — typing "Ravi" fires one request, not four.
  • distinctUntilChanged() — typing and deleting a character does not refire.
  • switchMap — each new term cancels the previous request, so an earlier slow response cannot overwrite a later fast one.

Without switchMap, throttle to Slow 3G and type quickly: the list ends up showing results for a prefix of what was typed. It looks like a caching bug and is a race.

catchError sits inside switchMap. On the outer stream, one failure terminates it and search stops working until the page is reloaded.

Retry and timeout

import { retry, timeout } from 'rxjs';

this.http.get<Student[]>(this.baseUrl).pipe(
timeout(10000),
retry({
count: 2,
delay: (error: HttpErrorResponse, retryCount) =>
error.status >= 500 || error.status === 0
? timer(retryCount * 1000)
: throwError(() => error)
})
);

Retry only transient failures — 5xx and network errors. Retrying a 400 or 404 fails identically and delays the real message.

Never retry a non-idempotent POST without an idempotency key; two retries can create three students.

File upload and download

upload(publicId: string, file: File): Observable<HttpEvent<void>> {
const formData = new FormData();
formData.append('photo', file, file.name);

return this.http.post<void>(`${this.baseUrl}/${publicId}/photo`, formData, {
reportProgress: true,
observe: 'events'
});
}
this.studentService.upload(publicId, file).subscribe(event => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.progress.set(Math.round(100 * event.loaded / event.total));
} else if (event.type === HttpEventType.Response) {
this.progress.set(100);
}
});

Do not set Content-Type with FormData. The browser sets multipart/form-data including the boundary; setting it yourself omits the boundary and the server cannot parse the body.

downloadReceipt(paymentId: number): Observable<Blob> {
return this.http.get(`${environment.apiUrl}/api/payments/${paymentId}/receipt`, {
responseType: 'blob'
});
}
this.feeService.downloadReceipt(id).subscribe(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `receipt-${id}.pdf`;
link.click();
URL.revokeObjectURL(url);
});

URL.revokeObjectURL releases the memory. Omitting it leaks a blob per download.

responseType: 'blob' also means errors arrive as a Blob, not JSON — reading the message needs await err.error.text().

Environments

// environments/environment.ts — production
export const environment = { production: true, apiUrl: 'https://api.nexcoding.in' };

// environments/environment.development.ts
export const environment = { production: false, apiUrl: 'https://localhost:7099' };

Always import the base environment; the build swaps the file.

Environment files ship to the browser. They hold URLs and feature flags — never an API key or a secret. There is no such thing as a client-side secret; ng build puts the value in a file anyone can read.

For local development against a different-origin API, a proxy avoids CORS entirely:

// proxy.conf.json
{
"/api": {
"target": "https://localhost:7099",
"secure": false,
"changeOrigin": true
}
}
ng serve --proxy-config proxy.conf.json

Requests to /api/students are forwarded by the dev server, so the browser sees a same-origin request. This only works in ng serve — production needs real CORS, which is why the proxy sometimes hides a problem until deployment.

CORS

Access to fetch at 'https://api.nexcoding.in/api/students'
from origin 'https://portal.nexcoding.in' has been blocked by CORS policy.

No Angular change fixes this. No HttpClient option, no header, no interceptor.

The request usually reached the server and succeeded — the server returned 200, and the browser then refused to hand the response to your code because Access-Control-Allow-Origin was missing. That is why the same call works in Postman, which is not a browser.

// The fix is in Program.cs, not in Angular
builder.Services.AddCors(options =>
{
options.AddPolicy("SchoolPortal", policy =>
policy.WithOrigins("https://portal.nexcoding.in")
.AllowAnyHeader()
.AllowAnyMethod());
});

app.UseRouting();
app.UseCors("SchoolPortal");
app.UseAuthentication();

An OPTIONS request returning 404 or 405 in the Network tab means CORS middleware is not wired up. One returning 401 means UseCors is after UseAuthentication — the preflight carries no token.

A CORS block surfaces in Angular as status: 0.

Diagnosing a failed call

Open the Network tab, filter to Fetch/XHR, then reproduce.

What you seeMeaning
No request at allNever subscribed, or the code never ran
(failed) or (canceled)Network failure, CORS block, or switchMap cancelled it
401Token missing or expired
403Wrong role
415Missing Content-Type — a bug in the caller
400Read err.error.errors for the fields
500Server-side; the browser cannot tell you why
200 but nothing rendersThe response arrived; the rendering code is wrong

Copy as cURL, then replay in Postman. Works there but fails in the browser → a frontend problem: CORS, a header, or the token. Fails in both → a backend problem.

A copied cURL contains your live Authorization header. Redact it before pasting it into a ticket.

Errors you will hit

What you seeCauseFix
NullInjectorError: No provider for HttpClientprovideHttpClient() missingAdd it
CORS error in the browser, fine in PostmanCORS is browser-enforced, server-configuredFix it on the API
Response fields are undefinedCasing mismatch with the API's JSONMatch exactly
The request never firesNot subscribedSubscribe or use async
401 on every callToken not attachedUse an interceptor
Errors show as [object Object]Rendered the whole HttpErrorResponseRead error.error or error.status

API URLs belong in src/environments, not in the service. Anything in the built bundle is visible to the user, so it is configuration, never a secret.

Common mistakes

  • Missing provideHttpClient()
  • Discarding the result of params.set(...)
  • Setting Authorization per call instead of in an interceptor
  • Trusting http.get<T>() as validation
  • Typing a date field as Date when it arrives as a string
  • Treating status: 0 as a server error
  • Ignoring the errors object on a 400
  • Only handling success — no loading, empty or error state
  • Hiding the spinner only on success
  • No debounce or switchMap on a live search
  • catchError outside switchMap
  • Retrying 4xx, or retrying a POST
  • Setting Content-Type with FormData
  • No revokeObjectURL after a download
  • A secret in an environment file
  • Trying to fix CORS in Angular

Practice

The course exercises are call a GET/POST API and handle API errors.

  1. Build StudentService with all five verbs. Confirm each in the Network tab.
  2. Write params.set('term', term) without reassigning. Confirm the filter is silently ignored.
  3. Search for 10th & A using HttpParams, then by string concatenation. Compare the request URLs.
  4. Type a date field as Date and call .getFullYear(). Record the runtime error.
  5. Implement all four states with a discriminated union. Force each.
  6. Stop the API and trigger a request. Confirm status: 0, and write the right message.
  7. POST an invalid body and map err.error.errors to form controls.
  8. Force a 500 and show the traceId. Find the matching entry in the API log.
  9. Build a live search with no debounce and no switchMap. Throttle to Slow 3G, type quickly, and confirm out-of-order results.
  10. Add debounceTime, distinctUntilChanged and switchMap. Confirm one result and cancelled requests.
  11. Put catchError on the outer stream, force one failure, then search again. Confirm search is dead. Move it inside.
  12. Add retry for 5xx only. Confirm a 400 is not retried.
  13. Upload a file with progress. Set Content-Type manually and confirm the server cannot parse it.
  14. Download a PDF as a blob. Omit revokeObjectURL, download fifty times, and watch memory.
  15. Configure proxy.conf.json and call the API without CORS. Then build and serve dist/ and confirm the CORS error appears.
  16. Copy a failing request as cURL and replay it in Postman.

Exercises 9 and 15 correspond to a visible race and a problem that only appears after deployment.

You can now

  • Call an API handling loading, success, empty and error
  • Recognise a CORS error and say where it is fixed
  • Keep the API URL in environment configuration
  • Prevent races between in-flight requests
  • Turn an HttpErrorResponse into a useful message

Review questions

  1. What does status: 0 mean, and what does it not mean?
  2. Why does params.set(...) need its result assigned?
  3. Why must catchError sit inside switchMap on a search stream?
  4. Why does a dev-server proxy sometimes hide a CORS problem until deployment?

Next: Authentication, interceptors and guards