Angular 35 ๐ ฐ๏ธ HTTP Client โ Talking to APIs
Most front-end applications exist to communicate with a server. Fetching user data, submitting forms, loading configuration, uploading files โ these are the operations that turn a static interface into a working application. Angular provides HttpClient for this purpose, a service built on the browser’s native fetch API (with XMLHttpRequest as an option) that wraps HTTP calls in RxJS Observables and integrates them with Angular’s dependency injection, interceptors, and testing utilities . The key characteristics are: requests are cold Observables that only fire when subscribed to, the generic type parameter is a type assertion rather than runtime validation, and the entire request and response flow can be intercepted and transformed globally . This chapter covers the setup, the request methods, error handling, headers and parameters, response type options, and the interceptor pattern that makes concerns like authentication and logging reusable across every request.
Key point: HttpClient is provided through provideHttpClient() and injected wherever needed. Its methods (get, post, put, patch, delete) return cold Observables โ no request is sent until subscribe() is called . The generic type parameter is a compile-time assertion; the actual response is not validated against it . Interceptors are functions that receive the outgoing request and a next handler, and can modify the request, transform the response, or handle errors before forwarding to the next interceptor in the chain .
Setting up HttpClient
HttpClient is provided through the provideHttpClient function, which is added to the application’s providers array. In Angular v21 and later, it is available for injection by default, so provideHttpClient() with no arguments is only needed when you want to configure features .
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient()],
};
Once provided, HttpClient is injected like any other service using inject() or constructor injection .
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
}
Why the service pattern is recommended. The Angular team explicitly recommends encapsulating backend calls in injectable services rather than components . A service centralizes the API logic, makes it reusable across components, and keeps the component focused on presentation. The service also provides a natural place for transforming responses, handling errors, and caching.
Why provideHttpClient instead of HttpClientModule. The older HttpClientModule is still supported for NgModule-based applications, but provideHttpClient is preferred for standalone applications and offers more stable behavior in multi-injector configurations . The HttpClientModule sets up withInterceptorsFromDi() and withXhr() by default, which are not the modern defaults.
Why the fetch backend is the default. Angular uses the browser’s fetch API by default, which is more modern and available in environments where XMLHttpRequest is not supported . The withXhr() feature switches back to XMLHttpRequest when upload progress events are required, because fetch does not produce them. The withXhr() option should not be used in server-side rendering environments because XHR support on the server is deprecated .
Why the Observable is cold. A cold Observable does not start producing values until it is subscribed to. This means
this.http.get('/api/users')creates a description of a request but does not send it. Calling.subscribe()sends it. This is why mutation requests โpost,put,patch,deleteโ must be subscribed to actually execute . The benefit of cold Observables is that they can be piped, transformed, retried, and composed before any network activity occurs.
Making requests
The HttpClient service has a method for each HTTP verb. Each returns an Observable of the response body, typed by the generic parameter .
// GET โ retrieve data
this.http.get<User[]>('/api/users').subscribe(users => {
console.log(users);
});
// POST โ create a resource
this.http.post<User>('/api/users', newUser).subscribe(created => {
console.log(created);
});
// PUT โ replace a resource
this.http.put<User>(`/api/users/${id}`, updatedUser).subscribe(user => {
console.log(user);
});
// DELETE โ remove a resource
this.http.delete<void>(`/api/users/${id}`).subscribe(() => {
console.log('Deleted');
});
The generic type parameter โ User, User[], void โ describes the expected response body. It does not validate the response at runtime; if the server returns something else, TypeScript will not catch it . For untrusted data, the unknown type is a safer choice, forcing the caller to validate before using the value.
Why the body argument comes before options. For post, put, and patch, the method signature is (url, body, options). The body is serialized automatically based on its type: a plain object becomes JSON, a FormData instance is sent as multipart form data, and HttpParams is sent as application/x-www-form-urlencoded . This means most calls do not need to manually set the Content-Type header.
Why subscription is required for mutations. Because the Observables are cold, a post call without .subscribe() never fires. This is a common mistake for developers coming from fetch or axios, where the request fires immediately. The fix is to subscribe, or to use the async pipe in a template, or to convert to a Promise with firstValueFrom .
Why async pipe is preferred in templates. Subscribing manually in a component requires unsubscribing in ngOnDestroy to prevent memory leaks. The async pipe handles subscription and unsubscription automatically, and it works with HttpClient Observables without any changes .
@Component({
template: `
@if (users$ | async; as users) {
@for (user of users; track user.id) {
<div>{{ user.name }}</div>
}
}
`,
})
export class UserListComponent {
private readonly userService = inject(UserService);
users$ = this.userService.getUsers();
}
Headers, parameters, and response types
Requests can be customized through the options object. The most common options are headers, params, responseType, and observe .
this.http.get<Config>('/api/config', {
headers: { 'X-Debug-Level': 'verbose' },
params: { filter: 'all', page: '1' },
responseType: 'json',
observe: 'response',
}).subscribe(response => {
console.log('Status:', response.status);
console.log('Body:', response.body);
});
Why HttpHeaders and HttpParams are immutable. Both classes use immutable data structures. Calling .set() or .append() returns a new instance rather than modifying the original. This is why chaining works: new HttpHeaders().set('A', '1').set('B', '2') produces a new instance with both headers . Mutating methods that return void do not exist; the returned instance must be used.
Why responseType needs a literal value. The responseType option affects the TypeScript return type of the method. If it is extracted into a variable typed as string, the compiler cannot narrow the return type correctly. The fix is to use as const: responseType: 'text' as const .
Why observe: 'response' is useful. By default, HttpClient returns the response body only. Setting observe: 'response' returns the full HttpResponse object, which includes status, headers, statusText, and url in addition to body. This is useful for reading response headers like pagination links or rate-limit information .
Response type values:
responseType | Returned type | Use case |
|---|---|---|
'json' (default) | Typed body | APIs returning JSON |
'text' | string | Plain text, CSV |
'blob' | Blob | Images, file downloads |
'arraybuffer' | ArrayBuffer | Binary data, raw bytes |
Error handling
HttpClient reports failures through the HttpErrorResponse class, which is delivered as an error notification on the Observable . The status property distinguishes network failures from server failures: status 0 indicates a network or CORS error, while status codes 4xx and 5xx come from the server.
import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
getUser(id: string): Observable<User> {
return this.http.get<User>(`/api/users/${id}`).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 0) {
console.error('Network error:', error.message);
} else if (error.status === 404) {
console.error('User not found');
} else {
console.error(`Server error: ${error.status}`);
}
return throwError(() => new Error('Failed to load user'));
}),
);
}
The catchError operator intercepts the error, logs it, and returns a new Observable via throwError. Returning throwError keeps the error chain going โ the subscriber’s error callback still fires . If catchError returned of(fallbackValue), the error would be swallowed and the subscriber would receive the fallback as a success.
Why HttpErrorResponse is the type to catch. The error object passed to catchError is an HttpErrorResponse instance. It has status, statusText, error (the response body, often a validation error object), message, url, and headers. Inspecting error.status distinguishes the failure modes .
Why status 0 is special. A status of 0 means the request never reached the server, or the response never came back. Common causes are network disconnection, CORS preflight failure, or a timeout. The error.error property is typically a ProgressEvent rather than a server response body .
Why retry logic belongs in an interceptor. Retrying failed requests with exponential backoff is a common pattern, and it is best implemented once as an interceptor rather than repeated in every service method . RxJS provides the retry and retryWhen operators for this, and placing them in an interceptor ensures every request benefits without service code changes.
Interceptors
Interceptors are middleware functions that process every request made through HttpClient. They receive the outgoing request and a next handler, and return an Observable of the response. They can modify the request, transform the response, handle errors, or short-circuit the chain entirely .
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (token) {
const cloned = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
});
return next(cloned);
}
return next(req);
};
The interceptor is a plain function with the signature (req: HttpRequest<unknown>, next: HttpHandlerFn) => Observable<HttpEvent<unknown>> . It injects dependencies using inject() because functional interceptors run in an injection context. The req.clone() method is required because HttpRequest instances are immutable โ the original cannot be modified .
Registering interceptors:
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]),
),
],
};
The withInterceptors feature takes an array of interceptor functions. They run in the order listed: authInterceptor processes the request first, then forwards to loggingInterceptor, which forwards to errorInterceptor, which forwards to the backend .
Why next must always be called. Every interceptor must return the result of calling next() with either the original request or a cloned version. If an interceptor returns without calling next(), the request chain is broken and the request never reaches the server. The only exception is an interceptor that deliberately short-circuits โ such as a cache interceptor returning a cached response without making a network call .
Why interceptors are the right place for cross-cutting concerns. Authentication tokens, loading spinners, logging, caching, retry logic, and request prefixing are concerns that apply to every request or a class of requests. Implementing them in each service method would duplicate code and create inconsistency. An interceptor runs once and applies uniformly .
Common interceptor patterns:
| Pattern | Purpose |
|---|---|
| Auth | Add Authorization header |
| Logging | Log request URL, method, duration |
| Loading indicator | Show spinner during requests |
| Caching | Return cached response for GET |
| API prefix | Prepend base URL to relative paths |
| Error handling | Global error toast or redirect |
Why functional interceptors are preferred. Angular recommends functional interceptors over the older class-based HttpInterceptor implementation because they have more predictable ordering and work naturally with the injection context . Class-based interceptors are still supported through withInterceptorsFromDi() for existing code, but new code should use the functional form.
Complete Example Session
import { Component, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { catchError, throwError, tap, finalize } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
// ============================================
// PART 1: AUTH INTERCEPTOR
// ============================================
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('token');
if (token) {
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}));
}
return next(req);
};
// ============================================
// PART 2: LOGGING INTERCEPTOR
// ============================================
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = Date.now();
return next(req).pipe(
tap({
next: (event) => {
if (event.type === 4 /* HttpEventType.Response */) {
console.log(`${req.method} ${req.url} โ ${Date.now() - started}ms`);
}
},
error: (err) => {
console.error(`${req.method} ${req.url} โ failed`, err);
},
}),
);
};
// ============================================
// PART 3: SERVICE WITH ERROR HANDLING
// ============================================
export interface User {
id: string;
name: string;
email: string;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/users';
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.baseUrl).pipe(
catchError(this.handleError),
);
}
getUser(id: string): Observable<User> {
return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
catchError(this.handleError),
);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.baseUrl, user).pipe(
catchError(this.handleError),
);
}
deleteUser(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
catchError(this.handleError),
);
}
private handleError(error: HttpErrorResponse) {
let message = 'An unknown error occurred';
if (error.status === 0) {
message = 'Network error โ check your connection';
} else if (error.status === 404) {
message = 'Resource not found';
} else if (error.status >= 500) {
message = 'Server error โ try again later';
}
console.error(message, error);
return throwError(() => new Error(message));
}
}
// ============================================
// PART 4: COMPONENT USING THE SERVICE
// ============================================
@Component({
selector: 'app-user-list',
standalone: true,
imports: [],
template: `
@if (loading()) {
<p>Loading...</p>
} @else if (error()) {
<p>Error: {{ error() }}</p>
<button (click)="load()">Retry</button>
} @else {
@for (user of users(); track user.id) {
<div>
{{ user.name }} โ {{ user.email }}
<button (click)="remove(user.id)">Delete</button>
</div>
}
}
`,
})
export class UserListComponent {
private readonly userService = inject(UserService);
readonly users = signal<User[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
load(): void {
this.loading.set(true);
this.error.set(null);
this.userService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.loading.set(false);
},
});
}
remove(id: string): void {
this.userService.deleteUser(id).subscribe({
next: () => {
this.users.update((list) => list.filter((u) => u.id !== id));
},
});
}
}
// ============================================
// PART 5: APP CONFIGURATION
// ============================================
import { ApplicationConfig } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor]),
),
],
};
The example demonstrates the complete flow: interceptors for authentication and logging, a service that encapsulates API calls with error handling, and a component that manages loading and error state through signals. The catchError in the service transforms HttpErrorResponse into a user-facing message, and the component subscribes to the Observable and updates its signals accordingly.
Quick Reference
HTTP Methods
| Method | Purpose | Body |
|---|---|---|
get<T>(url) | Retrieve data | No |
post<T>(url, body) | Create resource | Yes |
put<T>(url, body) | Replace resource | Yes |
patch<T>(url, body) | Partial update | Yes |
delete<T>(url) | Remove resource | No |
Request Options
| Option | Type | Purpose |
|---|---|---|
headers | Object or HttpHeaders | Custom headers |
params | Object or HttpParams | URL query parameters |
responseType | 'json' | 'text' | 'blob' | 'arraybuffer' | Expected response format |
observe | 'body' | 'response' | 'events' | What to return |
withCredentials | boolean | Send cookies |
reportProgress | boolean | Progress events |
Error Handling
| Status | Meaning |
|---|---|
0 | Network error, CORS, timeout |
400 | Bad request |
401 | Unauthorized |
404 | Not found |
500 | Server error |
Interceptor Pattern
| Step | Code |
|---|---|
| Define | export const auth: HttpInterceptorFn = (req, next) => next(req.clone({...})); |
| Register | withInterceptors([auth, logging]) |
| Order | Runs in array order |
Common Interceptor Uses
| Use Case | Purpose |
|---|---|
| Auth | Add Authorization header |
| Logging | Log requests and responses |
| Loading | Show spinner during requests |
| Caching | Return cached responses |
| Retry | Retry failed requests |
| Prefix | Add base URL |
Best Practices
โ Do This:
// Encapsulate API calls in a service
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
} // โ
// Subscribe to mutation requests
this.http.post('/api/users', user).subscribe(); // โ
// Use catchError for error handling
.pipe(catchError(this.handleError)) // โ
// Clone requests in interceptors
const cloned = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); // โ
// Use async pipe in templates
users$ = this.userService.getUsers(); // โ
// Handle status 0 as network error
if (error.status === 0) { /* network issue */ } // โ
โ Don’t Do This:
// Don't expect the generic type to validate at runtime
this.http.get<User>('/api/user') // type is assertion only // โ ๏ธ
// Don't forget to subscribe to mutations
this.http.post('/api/users', user); // nothing happens // โ ๏ธ
// Don't mutate requests in interceptors
req.headers.set('Authorization', token); // immutable // โ ๏ธ
// Don't subscribe without cleanup in components
this.http.get('/api').subscribe(); // leaks // โ ๏ธ
// Don't use withXhr in SSR
provideHttpClient(withXhr()) // deprecated on server // โ ๏ธ
// Don't swallow errors with of() in catchError
catchError(() => of(null)) // hides failure // โ ๏ธ
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Mutation not subscribed | Request never fires | .subscribe() |
| Immutable headers not cloned | Request unchanged | req.clone({ headers: ... }) |
catchError returns of(null) | Error swallowed | Return throwError |
| Status 0 treated as server error | Wrong diagnosis | Check for status 0 |
responseType not literal | Type inference broken | as const |
Interceptor doesn’t call next | Request chain broken | Always return next(...) |
| Memory leak from manual subscribe | Subscription not cleaned | Use async pipe or takeUntil |
| Generic type not validated | Runtime surprise | Validate with unknown |
Real-World Examples
1. Fetch list
this.http.get<User[]>('/api/users')
2. Create resource
this.http.post<User>('/api/users', newUser)
3. Update resource
this.http.put<User>(`/api/users/${id}`, updated)
4. Delete resource
this.http.delete<void>(`/api/users/${id}`)
5. Auth interceptor
export const auth: HttpInterceptorFn = (req, next) =>
next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))
6. Loading interceptor
export const loading: HttpInterceptorFn = (req, next) => {
spinner.show();
return next(req).pipe(finalize(() => spinner.hide()));
}
7. Retry interceptor
return next(req).pipe(retry(3))
8. Cache interceptor
if (req.method === 'GET' && cache.has(req.url)) return of(cache.get(req.url))
9. URL prefix interceptor
const apiReq = req.clone({ url: `https://api.example.com${req.url}` })
10. Full response with headers
this.http.get<User>('/api/user', { observe: 'response' })
Visual: Request Flow with Interceptors
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Component calls service.getUsers() โ
โ โ โ
โ โผ โ
โ HttpClient.get('/api/users') โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ INTERCEPTOR CHAIN โ โ
โ โ โ โ
โ โ authInterceptor โ โ
โ โ โโโ clone req, add Authorization header โ โ
โ โ โ โ โ
โ โ โผ โ โ
โ โ loggingInterceptor โ โ
โ โ โโโ log start time โ โ
โ โ โ โ โ
โ โ โผ โ โ
โ โ errorInterceptor โ โ
โ โ โโโ catchError, transform error โ โ
โ โ โ โ โ
โ โโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ Backend /api/users โ
โ โ โ
โ โผ โ
โ Response flows back through interceptors in reverse โ
โ โ โ
โ โผ โ
โ Subscriber receives data or error โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Cold Observable
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ const request = this.http.get('/api/users'); โ
โ โ โ
โ โ No request sent. โ
โ โ Observable is a description. โ
โ โ โ
โ โผ โ
โ request.subscribe({ โ
โ next: (data) => console.log(data), โ
โ error: (err) => console.error(err), โ
โ }); โ
โ โ โ
โ โ NOW the request fires. โ
โ โ โ
โ โผ โ
โ Backend receives GET /api/users โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ MUTATIONS MUST BE SUBSCRIBED โ
โ โ
โ this.http.post('/api/users', user); // โ no request โ
โ this.http.post('/api/users', user).subscribe(); // โ
โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Error Handling Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Request fails โ
โ โ โ
โ โผ โ
โ HttpErrorResponse โ
โ โ โ
โ โโโ status === 0 โโโบ Network/CORS error โ
โ โ โ
โ โโโ status === 401 โโโบ Unauthorized โ
โ โ โ
โ โโโ status === 404 โโโบ Not found โ
โ โ โ
โ โโโ status >= 500 โโโบ Server error โ
โ โ
โ โผ โ
โ catchError((error: HttpErrorResponse) => { โ
โ // Log, transform, or re-throw โ
โ return throwError(() => new Error('User message')); โ
โ }) โ
โ โ
โ Subscriber's error callback receives the error โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: When to Use HttpClient vs httpResource
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Do you need signal-based reactive state? โ
โ โ โ
โ โโโ Yes โโโบ httpResource โ
โ โ - Automatic refetch on signal change โ
โ โ - Loading/error state signals โ
โ โ - Cancellation on change โ
โ โ โ
โ โโโ No โโโโบ HttpClient โ
โ - Full control over subscription โ
โ - Manual state management โ
โ - Transform with RxJS operators โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Provider | provideHttpClient() |
| Injection | inject(HttpClient) |
| Methods | get, post, put, patch, delete |
| Return type | Cold Observable<T> |
| Subscription | Required for execution |
| Error type | HttpErrorResponse |
| Status 0 | Network/CORS failure |
| Interceptor | HttpInterceptorFn |
| Registration | withInterceptors([...]) |
| Ordering | Array order |
| Immutability | req.clone() required |
Key takeaways:
HttpClientis provided throughprovideHttpClient()and injected wherever needed โ no module import required in modern Angular- Requests are cold Observables โ no request fires until
.subscribe()is called, and mutation requests must be subscribed to execute - The generic type parameter is a type assertion only โ it describes the expected response but does not validate it at runtime
HttpErrorResponsedistinguishes failure modes through itsstatusproperty โ status0is a network or CORS error, while4xxand5xxcome from the server- Interceptors are functions that run for every request โ they can modify requests, transform responses, handle errors, and short-circuit the chain
- Functional interceptors are preferred over class-based for their predictable ordering and injection context compatibility
- Requests are immutable โ interceptors must clone them with
req.clone()before modifying headers or other properties - The service pattern is recommended โ encapsulate API calls in injectable services rather than components
- Always handle errors with
catchErrorand returnthrowErrorto keep the error chain alive - Use the
asyncpipe or explicit cleanup for component subscriptions to prevent memory leaks
Remember: HttpClient is the bridge between Angular applications and backend APIs. Requests are Observables, interceptors are middleware, and errors are structured. The cold Observable characteristic is the most common source of confusion โ nothing happens until you subscribe. Once that is internalized, the rest is configuration: headers, parameters, response types, and the interceptor chain that makes authentication, logging, and error handling reusable across every request.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!