Vue.js 42 🟢 Async Components and Lazy Loading via defineAsyncComponent()
A Vue application ships as JavaScript. The larger the application, the larger the bundle, and the longer the initial load. A user who lands on the login page does not need the code for the settings panel, the admin dashboard, or the rich text editor. Loading all of it upfront wastes bandwidth and delays the first paint. Async components are the mechanism that splits the bundle: the component’s code is fetched only when it is needed. Vue 3 provides defineAsyncComponent() for this, and the build tooling (Vite, webpack) turns each dynamically imported component into a separate chunk.
Key point: defineAsyncComponent() wraps a dynamic import() and returns a component that Vue renders when the chunk arrives. The component is fetched on first render, cached after that, and can be configured with loading, error, delay, and timeout behavior. Lazy loading is a build-time optimization expressed at the component level.
Why async components exist
The bundle-size problem. A single bundle that contains the entire application is downloaded by every user on every visit. The login page pays for the admin dashboard. The landing page pays for the video player. Bundle size grows with the application, and the initial load time grows with it.
The code-splitting problem. Build tools can split a bundle into chunks, but only if the code is structured to allow it. A static import statement tells the bundler that the module is needed at startup. A dynamic import() tells the bundler that the module can be loaded later. defineAsyncComponent() is the wrapper that makes the dynamic import usable as a component.
The route-level problem. Route-based code splitting is the most common pattern. Each route’s component is loaded only when the user navigates to that route. Vue Router supports this directly with dynamic imports, and defineAsyncComponent() provides the same behavior at the component level for cases where the split is finer-grained than a route.
The heavy-component problem. Some components are large and rarely used: a rich text editor, a charting library, a PDF viewer, a code editor. Loading them upfront inflates the bundle for users who never open them. Wrapping them in defineAsyncComponent() defers the cost to the moment they are needed.
The conditional-render problem. A component that renders only when a condition is true, such as a modal that opens on a button click, can be lazy-loaded. The chunk is fetched when the modal first appears, not when the page loads.
The performance-budget problem. Performance budgets are measured in kilobytes of JavaScript. Code splitting is the primary tool for staying within a budget. Every component that is not needed for the initial render is a candidate for defineAsyncComponent().
a. The basics of defineAsyncComponent
defineAsyncComponent() accepts a loader function and returns a component. The loader must return a Promise that resolves to the component definition.
The minimal usage
<script setup>
import { defineAsyncComponent } from 'vue';
const AsyncEditor = defineAsyncComponent(() =>
import('./components/Editor.vue')
);
</script>
<template>
<AsyncEditor />
</template>
The import('./components/Editor.vue') is a dynamic import. The bundler splits Editor.vue into a separate chunk. The chunk is fetched when AsyncEditor first renders.
The parent’s perspective
The async component is used like any other component. The parent does not need to know that it is loaded on demand.
<template>
<AsyncEditor v-model="content" @save="handleSave" />
</template>
Props, events, slots, and refs work the same way. The difference is the timing of the code fetch.
The loading state
The default behavior shows nothing while the chunk is loading. To show a loading indicator, pass an options object with a loadingComponent.
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
const AsyncEditor = defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
loadingComponent: LoadingSpinner,
delay: 200,
});
</script>
The delay option postpones the loading component for 200 milliseconds. If the chunk arrives faster, the loading indicator never appears, which avoids a flash of content that immediately disappears.
The error state
If the chunk fails to load, the component renders an errorComponent instead.
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
import ErrorDisplay from './ErrorDisplay.vue';
const AsyncEditor = defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 30000,
});
</script>
The timeout option fails the component if the chunk does not load within the specified milliseconds.
The onError callback
The onError option receives the error, the retry function, the fail function, and the number of attempts. It decides whether to retry or fail.
<script setup>
import { defineAsyncComponent } from 'vue';
const AsyncEditor = defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry();
} else {
fail();
}
},
});
</script>
The retry loop is useful for flaky networks. The attempts argument limits the number of retries.
The default timeout
The default timeout is Infinity, which means the component waits forever. In practice, a finite timeout is almost always better, because a chunk that never loads leaves the user with a blank area and no error.
b. Lazy loading patterns
The placement of defineAsyncComponent() determines what is loaded when. The patterns below cover the common cases.
Route-level lazy loading
Vue Router accepts a dynamic import directly as the component for a route. This is the most common form of code splitting.
const routes = [
{
path: '/',
component: () => import('./views/Home.vue'),
},
{
path: '/settings',
component: () => import('./views/Settings.vue'),
},
{
path: '/admin',
component: () => import('./views/Admin.vue'),
},
];
Each route’s component is a separate chunk. The chunk is fetched when the user navigates to the route. The Home chunk is fetched on the first visit, and the Settings and Admin chunks are fetched only when their routes are visited.
Conditional lazy loading
A component that renders only when a condition is true can be lazy-loaded without a route.
<script setup>
import { ref, defineAsyncComponent } from 'vue';
const showEditor = ref(false);
const AsyncEditor = defineAsyncComponent(() => import('./Editor.vue'));
</script>
<template>
<button @click="showEditor = true">Open editor</button>
<AsyncEditor v-if="showEditor" />
</template>
The Editor chunk is fetched the first time the button is clicked. Subsequent toggles use the cached chunk.
Interaction-triggered lazy loading
A component can be lazy-loaded before it is rendered, using an event handler that triggers the import.
<script setup>
import { ref } from 'vue';
const AsyncEditor = ref(null);
async function preloadEditor() {
AsyncEditor.value = (await import('./Editor.vue')).default;
}
</script>
<template>
<button @mouseenter="preloadEditor" @click="showEditor = true">
Open editor
</button>
<component :is="AsyncEditor" v-if="showEditor" />
</template>
The chunk is fetched when the user hovers the button, so it is ready when they click. This is a common pattern for prefetching on the interaction that is likely to lead to the render.
The Suspense integration
<Suspense> wraps async components and shows a fallback while they load. It replaces the per-component loading component with a single boundary.
<template>
<Suspense>
<template #default>
<AsyncEditor />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
The fallback shows until all async components inside the boundary are resolved. Nested Suspense boundaries allow finer-grained control.
The error boundary
Vue 3 does not have a built-in error boundary component, but the onErrorCaptured hook serves the same purpose. It catches errors from child components, including failed async loads.
<script setup>
import { onErrorCaptured, ref } from 'vue';
const hasError = ref(false);
onErrorCaptured((err) => {
hasError.value = true;
return false;
});
</script>
<template>
<div v-if="hasError">Failed to load component.</div>
<slot v-else />
</template>
The onErrorCaptured hook is called when a child throws. Returning false stops the propagation.
c. defineAsyncComponent versus other loading patterns
defineAsyncComponent() is one tool among several. The alternatives cover different cases.
Static import
A static import at the top of the script loads the module at build time and includes it in the main bundle. This is the default and is correct for components that are always rendered.
<script setup>
import Editor from './Editor.vue';
</script>
No lazy loading. The component is available immediately, and the bundler does not split it.
Dynamic import without the wrapper
A dynamic import() in a route or in an event handler returns a Promise. The router accepts the Promise directly, and the component is loaded when the route is resolved. This is the same mechanism as defineAsyncComponent(), without the loading, error, and timeout options.
const routes = [
{ path: '/editor', component: () => import('./Editor.vue') },
];
For routes, the router’s own loading behavior is often sufficient, and defineAsyncComponent() is unnecessary.
The import() in a template ref
A component can be imported dynamically and assigned to a ref. The ref is then used with <component :is>. This is more manual than defineAsyncComponent() and is rarely the right choice.
<script setup>
import { ref } from 'vue';
const AsyncEditor = ref(null);
async function load() {
AsyncEditor.value = (await import('./Editor.vue')).default;
}
</script>
<template>
<component :is="AsyncEditor" />
</template>
The defineAsyncComponent() wrapper handles the loading and error states, so the manual version is not needed except for unusual cases.
The comparison
| Pattern | Loading UI | Error UI | Timeout | Use Case |
|---|---|---|---|---|
| Static import | N/A | N/A | N/A | Always-rendered components |
| Router dynamic import | Router default | Router default | No | Routes |
defineAsyncComponent | Configurable | Configurable | Yes | Components with custom behavior |
Manual import() + ref | None | None | No | Very rare |
The bundle inspector
The effect of code splitting is visible in the build output. Vite and webpack report each chunk and its size.
npm run build
# dist/assets/Home-abc123.js 12.4 kB
# dist/assets/Settings-def456.js 8.2 kB
# dist/assets/Editor-ghi789.js 142.7 kB
The Editor chunk is the largest and is the best candidate for lazy loading. The Home chunk is small and can be in the main bundle.
The prefetch
Vite generates prefetch hints for chunks that are dynamically imported. The browser fetches them during idle time, so they are ready before the user navigates. The /* @vite-ignore */ comment disables the hint when it is not wanted.
const AsyncEditor = defineAsyncComponent(() =>
import(/* webpackPrefetch: true */ './Editor.vue')
);
Webpack uses the magic comment. Vite uses import.meta.glob for more control.
Complete Example Session
<!-- ============================================
PART 1: BASIC ASYNC COMPONENT
============================================ -->
<script setup>
import { defineAsyncComponent } from 'vue';
const AsyncEditor = defineAsyncComponent(() =>
import('./components/Editor.vue')
);
</script>
<template>
<AsyncEditor />
</template>
<!-- ============================================
PART 2: WITH LOADING COMPONENT
============================================ -->
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
const AsyncEditor = defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
loadingComponent: LoadingSpinner,
delay: 200,
});
</script>
<template>
<AsyncEditor />
</template>
<!-- ============================================
PART 3: WITH ERROR AND TIMEOUT
============================================ -->
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
import ErrorDisplay from './ErrorDisplay.vue';
const AsyncEditor = defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 30000,
});
</script>
<template>
<AsyncEditor />
</template>
<!-- ============================================
PART 4: WITH RETRY
============================================ -->
<script setup>
import { defineAsyncComponent } from 'vue';
const AsyncEditor = defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
onError(error, retry, fail, attempts) {
if (attempts <= 3) retry();
else fail();
},
});
</script>
<!-- ============================================
PART 5: ROUTE-LEVEL LAZY LOADING
============================================ -->
<script>
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/settings', component: () => import('./views/Settings.vue') },
{ path: '/admin', component: () => import('./views/Admin.vue') },
],
});
export default router;
</script>
<!-- ============================================
PART 6: CONDITIONAL LAZY LOADING
============================================ -->
<script setup>
import { ref, defineAsyncComponent } from 'vue';
const showEditor = ref(false);
const AsyncEditor = defineAsyncComponent(() => import('./Editor.vue'));
</script>
<template>
<button @click="showEditor = true">Open editor</button>
<AsyncEditor v-if="showEditor" />
</template>
<!-- ============================================
PART 7: PREFETCH ON HOVER
============================================ -->
<script setup>
import { ref } from 'vue';
const AsyncEditor = ref(null);
async function preload() {
if (!AsyncEditor.value) {
AsyncEditor.value = (await import('./Editor.vue')).default;
}
}
</script>
<template>
<button @mouseenter="preload" @click="showEditor = true">
Open editor
</button>
<component :is="AsyncEditor" v-if="showEditor" />
</template>
<!-- ============================================
PART 8: SUSPENSE INTEGRATION
============================================ -->
<template>
<Suspense>
<template #default>
<AsyncEditor />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<!-- ============================================
PART 9: ERROR BOUNDARY WITH onErrorCaptured
============================================ -->
<script setup>
import { onErrorCaptured, ref } from 'vue';
const hasError = ref(false);
onErrorCaptured((err) => {
hasError.value = true;
return false;
});
</script>
<template>
<div v-if="hasError">Failed to load component.</div>
<slot v-else />
</template>
<!-- ============================================
PART 10: BUILD OUTPUT
============================================ -->
<!--
npm run build
dist/assets/index-abc123.js 52.3 kB
dist/assets/Home-def456.js 12.4 kB
dist/assets/Settings-ghi789.js 8.2 kB
dist/assets/Editor-jkl012.js 142.7 kB
The Editor chunk is the largest.
It is loaded only when the Editor component is rendered.
The initial bundle does not include it.
-->
The ten parts showed basic async components, loading and error states, retry, route-level lazy loading, conditional lazy loading, prefetch on hover, Suspense integration, error boundaries, and the build output.
Quick Reference
defineAsyncComponent Options
| Option | Purpose | Default |
|---|---|---|
loader | Function returning a Promise | Required |
loadingComponent | Shown while loading | None |
errorComponent | Shown on error | None |
delay | Milliseconds before loading component | 200 |
timeout | Milliseconds before failing | Infinity |
suspensible | Use with Suspense | true |
onError | Custom error handler | None |
Loading Patterns
| Pattern | Where | When Loaded |
|---|---|---|
| Route-level | Router config | On navigation |
| Conditional | v-if in template | When condition is true |
| Prefetch on hover | Event handler | On hover |
| Suspense | Boundary component | When rendered |
| Always | Static import | At startup |
Lazy Loading Decision
| Component | Lazy? | Reason |
|---|---|---|
| Home page | No | Needed for first paint |
| Login form | No | First interaction |
| Admin dashboard | Yes | Rarely visited |
| Rich text editor | Yes | Large, conditional |
| Settings panel | Yes | Not on critical path |
| Modal | Yes | Rendered on demand |
Build Tool Comments
| Tool | Comment | Effect |
|---|---|---|
| webpack | /* webpackChunkName: "editor" */ | Names the chunk |
| webpack | /* webpackPrefetch: true */ | Prefetches during idle |
| Vite | import.meta.glob | Batch imports |
| Vite | /* @vite-ignore */ | Disables processing |
Best Practices
✅ Do This:
<!-- Lazy-load heavy components. -->
const AsyncEditor = defineAsyncComponent(() => import('./Editor.vue')); // ✅
<!-- Provide a loading component. -->
loadingComponent: LoadingSpinner // ✅
<!-- Set a finite timeout. -->
timeout: 30000 // ✅
<!-- Use route-level lazy loading. -->
{ path: '/admin', component: () => import('./Admin.vue') } // ✅
<!-- Retry on transient failures. -->
onError(error, retry, fail, attempts) { if (attempts <= 3) retry(); } // ✅
<!-- Use Suspense for nested async components. -->
<Suspense><AsyncEditor /></Suspense> // ✅
<!-- Prefetch on hover for likely interactions. -->
@mouseenter="preload" // ✅
❌ Don’t Do This:
<!-- Don't lazy-load the initial route. -->
{ path: '/', component: () => import('./Home.vue') } // ⚠️ Only if needed
<!-- Don't lazy-load tiny components. -->
const AsyncButton = defineAsyncComponent(() => import('./Button.vue')); // ❌ Overhead
<!-- Don't use an infinite timeout in production. -->
timeout: Infinity // ❌ Default; set a finite value
<!-- Don't skip the error component. -->
// No errorComponent → silent failure // ❌
<!-- Don't lazy-load everything. -->
// Each chunk adds a request. // ❌
<!-- Don't forget the retry logic on flaky networks. -->
// Default behavior fails on first error. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Loading flash | Chunk loads faster than delay | Set delay to 200ms |
| Blank area on failure | No error component | Provide errorComponent |
| Infinite wait | Default timeout is Infinity | Set a finite timeout |
| Chunk not split | Static import used | Use dynamic import() |
| Retry loops forever | No attempt limit | Check attempts |
| Suspense fallback never shows | Component loads fast | Expected; fallback only on slow loads |
| Type error in parent | Async component not typed | Use Component type or infer |
| Prefetch on every component | Prefetch hints for all chunks | Only prefetch likely next views |
Real-World Examples
1. Basic Async
const AsyncEditor = defineAsyncComponent(() => import('./Editor.vue'))
2. With Loading
const Async = defineAsyncComponent({ loader: () => import('./Editor.vue'), loadingComponent: Spinner })
3. With Error
const Async = defineAsyncComponent({ loader: () => import('./Editor.vue'), errorComponent: ErrorDisplay })
4. With Timeout
const Async = defineAsyncComponent({ loader: () => import('./Editor.vue'), timeout: 30000 })
5. With Retry
onError(error, retry, fail, attempts) { if (attempts <= 3) retry(); else fail(); }
6. Route-Level
{ path: '/admin', component: () => import('./Admin.vue') }
7. Conditional
<AsyncEditor v-if="showEditor" />
8. Prefetch on Hover
@mouseenter="preload"
9. Suspense
<Suspense><template #fallback><Spinner /></template><AsyncEditor /></Suspense>
10. Error Boundary
onErrorCaptured((err) => { hasError.value = true; return false; })
Visual
Code Splitting
┌────────────────────────────────────────────────────────────┐
│ WITHOUT LAZY LOADING │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ main.js (entire application) │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Home + Settings + Admin + Editor + ... │ │ │
│ │ │ Size: 850 kB │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Downloaded on first visit. │
│ │
│ ────────────────────────────────────────────────────── │
│ │
│ WITH LAZY LOADING │
│ │
│ ┌──────────────┐ │
│ │ main.js │ 52 kB ← Loaded on startup │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Home.js │ 12 kB ← Loaded when / visited │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Editor.js │ 142 kB ← Loaded when editor opens │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Admin.js │ 98 kB ← Loaded when /admin visited │
│ └──────────────┘ │
│ │
│ Initial load: 52 kB instead of 850 kB. │
└────────────────────────────────────────────────────────────┘
Async Component Lifecycle
┌────────────────────────────────────────────────────────────┐
│ ASYNC COMPONENT LIFECYCLE │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 1. RENDER TRIGGERED │ │
│ │ Component reaches the render phase │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 2. LOADER CALLED │ │
│ │ import() starts the chunk fetch │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 3. DELAY PERIOD (default 200ms) │ │
│ │ Nothing shown yet │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 4. LOADING COMPONENT SHOWN (if still loading) │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ v v │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ CHUNK LOADS │ │ TIMEOUT/ERROR │ │
│ │ Show component │ │ Show error │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
The Interface Decision
┌────────────────────────────────────────────────────────────┐
│ WHEN TO LAZY LOAD │
│ │
│ Component always rendered? │
│ → Static import │
│ │
│ Route-level? │
│ → Router dynamic import │
│ │
│ Large and conditional? │
│ → defineAsyncComponent │
│ │
│ Small and conditional? │
│ → Static import (chunk overhead > benefit) │
│ │
│ Part of a group of related views? │
│ → Group into a single chunk │
│ │
│ On the critical rendering path? │
│ → Static import (do not delay first paint) │
└────────────────────────────────────────────────────────────┘
Suspense Boundary
┌────────────────────────────────────────────────────────────┐
│ SUSPENSE BOUNDARY │
│ │
│ <Suspense> │
│ ┌────────────────────────────────────────────────┐ │
│ │ #default │ │
│ │ <AsyncEditor /> │ │
│ │ <AsyncChart /> │ │
│ │ <AsyncPanel /> │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ #fallback │ │
│ │ <LoadingSpinner /> │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ The fallback shows until ALL async components inside │
│ the boundary have resolved. │
│ │
│ Nested Suspense allows finer control: │
│ <Suspense> │
│ <Suspense><AsyncA /></Suspense> │
│ <Suspense><AsyncB /></Suspense> │
│ </Suspense> │
│ │
│ Each inner boundary shows its own fallback. │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
defineAsyncComponent() | Wraps a dynamic import into a component |
| Loader | Function returning a Promise |
loadingComponent | Shown while loading |
errorComponent | Shown on failure |
delay | Milliseconds before showing the loading component |
timeout | Milliseconds before failing |
onError | Custom retry/fail handler |
| Route-level splitting | Dynamic import in the router |
| Suspense | Boundary for async components |
onErrorCaptured | Error boundary hook |
Key takeaways:
defineAsyncComponent()wraps a dynamic import. The loader function returns a Promise that resolves to the component. The bundler splits the component into a separate chunk, and Vue fetches it on first render.- Lazy loading is a build-time optimization. The dynamic import is what signals the bundler. Without it, the component stays in the main bundle.
- Route-level splitting is the most common pattern. Vue Router accepts dynamic imports directly. Each route’s component is a chunk loaded on navigation.
- Conditional rendering can also be lazy. A component that renders only when a condition is true is fetched the first time it appears.
- The
delayoption prevents loading flashes. Settingdelay: 200postpones the loading component for 200ms. Fast chunks never show it. - The default timeout is Infinity. A finite timeout is almost always better, because a chunk that never loads leaves a blank area with no error.
onErrorenables retry logic. The handler receives the retry and fail functions and the attempt count. It decides whether to retry or give up.- Suspense replaces per-component loading. The boundary shows a single fallback until all nested async components resolve.
- The build output shows the effect. Each chunk’s size is reported, and the largest chunks are the best candidates for lazy loading.
Remember: defineAsyncComponent() is the Vue 3 mechanism for loading a component’s code on demand. It wraps a dynamic import, returns a component, and configures the loading, error, delay, and timeout behavior. Route-level lazy loading uses the same dynamic import directly in the router config. The result is a smaller initial bundle, faster first paint, and code that is fetched only when it is needed. The choice of what to lazy-load depends on the rendering path: components that render on first paint should be static, and components that render conditionally or on navigation are candidates for splitting. The build output reports each chunk, and the largest chunks are the best candidates. Use loadingComponent and errorComponent to make the loading experience clean, set a finite timeout, and add retry logic for transient failures. Lazy loading is a performance optimization that costs nothing at runtime once the chunk is cached and pays for itself on every visit.
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!