Telegram Mini App Performance Optimisation: Speed Strategies for Better Retention
Speed is not a luxury in the Telegram ecosystem โ it's survival. Users expect instant gratification. Every millisecond of load time is a chance to lose them to the next mini app in their chat list. This guide covers proven strategies to make your Telegram Web App (TWA) load faster, run smoother, and keep users engaged longer.
Why Performance Matters More in Telegram
Telegram mini apps operate in a unique environment that amplifies performance concerns:
- No install barrier: Users can switch apps instantly โ no uninstall friction keeping them
- Context switching: Users open mini apps mid-conversation; interruptions are constant
- Mobile-first: Most users are on mobile networks with variable quality
- WebView constraints: Running inside Telegram's WebView adds overhead
- Competition: Thousands of mini apps compete for the same attention
Key insight: In Telegram, your mini app doesn't compete with other websites โ it competes with native apps, games, and the chat interface itself. Performance is your differentiator.
Understanding the Telegram Mini App Environment
Before optimising, understand the constraints:
WebView Limitations
Telegram mini apps run inside platform-specific WebViews with specific characteristics:
- iOS: WKWebView with JIT compilation disabled (slower JavaScript)
- Android: Chrome Custom Tabs or WebView depending on version
- Desktop: Electron-based WebView with different performance profile
- Memory limits: WebViews have stricter memory constraints than browsers
- Caching: WebView cache behaviour differs from standard browsers
Network Considerations
Telegram users span the globe with varying connectivity:
- Emerging markets often use 2G/3G networks
- Data costs matter โ smaller bundles save users money
- Intermittent connectivity requires offline-first thinking
- CDN proximity varies significantly by region
Bundle Size Optimisation
Your initial JavaScript bundle is the biggest performance killer. Here's how to shrink it:
Code Splitting// Instead of loading everything upfront
import { HeavyComponent } from './HeavyComponent';
// Use dynamic imports for route-based splitting
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
// Or use @loadable/component for more control
import loadable from '@loadable/component';
const AdminPanel = loadable(() => import('./AdminPanel'), {
fallback:
});
Tree Shaking
// Bad โ imports entire library
import _ from 'lodash';
_.debounce(fn, 300);
// Good โ imports only what you need
import debounce from 'lodash/debounce';
// Better โ use esbuild/vite which tree-shake automatically
// Configure your bundler:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
telegram: ['@telegram-apps/sdk']
}
}
}
}
}
Dependency Audit
# Analyse your bundle size
npm install -g webpack-bundle-analyzer
# or
npx vite-bundle-visualizer
# Check for duplicate dependencies
npm ls package-name
# Replace heavy libraries
# moment.js (290KB) โ date-fns (15KB)
# lodash (70KB) โ radash (5KB) or native methods
# axios (50KB) โ fetch API (built-in)
Asset Optimisation
Images and fonts often bloat bundles unnecessarily:
- Images: Use WebP with JPEG fallback; implement lazy loading
- Icons: Use SVG sprites instead of icon fonts
- Fonts: Subset fonts to include only needed characters
- Animations: Prefer CSS animations over JavaScript libraries
// Lazy load images below the fold
import { useInView } from 'react-intersection-observer';
function LazyImage({ src, alt }) {
const { ref, inView } = useInView({ triggerOnce: true });
return (
{inView ? (
) : (
)}
);
}
Caching Strategies
Smart caching can make your app feel instant on subsequent visits:
Service Worker Setup// vite-pwa-plugin configuration
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.yourapp\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
}
})
]
};
Telegram CloudStorage API
// Use Telegram's built-in storage for user preferences
import { cloudStorage } from '@telegram-apps/sdk';
// Cache user settings
async function cacheUserSettings(settings) {
await cloudStorage.setItem('user_settings', JSON.stringify(settings));
}
// Retrieve cached settings instantly
async function getCachedSettings() {
const cached = await cloudStorage.getItem('user_settings');
return cached ? JSON.parse(cached) : null;
}
// Cache API responses for offline access
async function fetchWithCache(key, fetcher) {
const cached = await cloudStorage.getItem(`cache_${key}`);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
// Return stale data while refreshing
if (Date.now() - timestamp < 300000) { // 5 min
return data;
}
}
const fresh = await fetcher();
await cloudStorage.setItem(`cache_${key}`, JSON.stringify({
data: fresh,
timestamp: Date.now()
}));
return fresh;
}
Rendering Optimisation
How you render matters as much as what you render:
Virtual Scrolling
For lists with hundreds of items, virtual scrolling is essential:
import { Virtuoso } from 'react-virtuoso';
// Instead of rendering all 1000 items
function ItemList({ items }) {
return (
(
)}
overscan={5} // Render 5 items above/below viewport
/>
);
}
Memoisation
import { memo, useMemo, useCallback } from 'react';
// Prevent unnecessary re-renders
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onUpdate }) {
const processed = useMemo(() => {
return heavyComputation(data);
}, [data]);
const handleClick = useCallback(() => {
onUpdate(processed.id);
}, [onUpdate, processed.id]);
return {processed.value};
});
// Use React DevTools Profiler to identify slow components
Telegram-Specific Optimisations
Leverage Telegram's unique features for better performance:
Haptic Feedback Timingimport { hapticFeedback } from '@telegram-apps/sdk';
// Use haptic feedback to mask loading perception
async function handleAction() {
hapticFeedback.impactOccurred('light');
// Show immediate UI update
setOptimisticState(newState);
// Perform actual operation
await apiCall();
// Confirm with stronger haptic
hapticFeedback.notificationOccurred('success');
}
MainButton Loading States
import { mainButton } from '@telegram-apps/sdk';
function setLoadingState(isLoading) {
mainButton.setParams({
text: isLoading ? 'Processing...' : 'Continue',
isVisible: true,
isEnabled: !isLoading
});
if (isLoading) {
mainButton.showProgress(false); // false = don't disable button
} else {
mainButton.hideProgress();
}
}
Viewport Optimisation
import { viewport } from '@telegram-apps/sdk';
// Adjust layout based on available space
function useViewportOptimisation() {
const [isCompact, setIsCompact] = useState(false);
useEffect(() => {
const unsubscribe = viewport.on('change', (state) => {
// Use compact mode for smaller viewports
setIsCompact(state.height < 600);
// Adjust virtual list height
setListHeight(state.height - headerHeight);
});
return unsubscribe;
}, []);
return isCompact;
}
Network Optimisation
Make every byte count over the wire:
Request Batching
// Batch multiple small requests into one
class RequestBatcher {
constructor(batchFn, delay = 50) {
this.batchFn = batchFn;
this.delay = delay;
this.queue = [];
this.timeout = null;
}
add(request) {
return new Promise((resolve, reject) => {
this.queue.push({ request, resolve, reject });
this.scheduleBatch();
});
}
scheduleBatch() {
if (this.timeout) return;
this.timeout = setTimeout(() => {
this.flush();
}, this.delay);
}
async flush() {
const batch = this.queue.splice(0);
this.timeout = null;
try {
const results = await this.batchFn(batch.map(b => b.request));
batch.forEach((item, i) => item.resolve(results[i]));
} catch (error) {
batch.forEach(item => item.reject(error));
}
}
}
// Usage: batch analytics events
const analyticsBatcher = new RequestBatcher(
(events) => fetch('/api/analytics/batch', {
method: 'POST',
body: JSON.stringify({ events })
})
);
GraphQL Query Optimisation
// Request only fields you need
const USER_PROFILE_FRAGMENT = gql`
fragment UserProfile on User {
id
name
avatar
# Don't fetch fields you won't display
}
`;
// Use persisted queries to reduce payload size
const PERSISTED_QUERY = {
version: 1,
sha256Hash: "abc123..."
};
// Response will be cached at CDN edge
fetch('/graphql', {
method: 'POST',
body: JSON.stringify({
extensions: {
persistedQuery: PERSISTED_QUERY
}
})
});
Performance Monitoring
You can't optimise what you don't measure:
// Core Web Vitals tracking
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
// Send to your analytics endpoint
fetch('/api/performance', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
telegramUserId: window.Telegram?.WebApp?.initDataUnsafe?.user?.id
})
});
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
// Custom mini app metrics
const miniAppMetrics = {
timeToInteractive: performance.now(),
bundleSize: document.querySelector('script[src*="main"]')?.src.length,
apiLatency: []
};
Performance Checklist
Before releasing your mini app:
- Bundle size under 200KB (gzipped)
- First Contentful Paint under 1.5s on 3G
- Time to Interactive under 3s
- Service worker caching strategy implemented
- Images optimised and lazy-loaded
- Code splitting for routes and heavy components
- Virtual scrolling for lists >50 items
- Memoisation applied to expensive renders
- Telegram CloudStorage used for user preferences
- Haptic feedback for action confirmation
- Offline-first data strategy
- Performance monitoring in production
- Tested on low-end Android devices
- Tested on iOS with Low Power Mode
Testing on Real Devices
Simulator performance is misleading. Test on:
- Low-end Android: Samsung A-series, Xiaomi Redmi
- Older iPhones: iPhone 8, iPhone X
- Slow networks: Use Chrome DevTools throttling
- Low battery: iOS throttles performance when battery <20%
- Hot devices: Performance degrades when phones overheat
Final Thoughts
Performance optimisation is not a one-time task โ it's a continuous discipline. Start with the big wins (bundle size, caching), then iterate based on real user metrics. The fastest mini app wins in Telegram's attention economy.
Remember: users don't care about your tech stack. They care about how fast they can get what they came for. Optimise for their experience, not developer convenience.
Next step: Once your app is optimised, read our guide on measuring Telegram mini app ROI to track how performance improvements impact your bottom line.