edit_square igindin

Telegram Mini Apps: 10 Pitfalls Nobody Warns You About

TMA SDK has undocumented quirks that waste hours. Here are 10 real ones from building MeetCal — a scheduling app inside Telegram.

Ilya Gindin

I’ve been building MeetCal — a scheduling app that lives inside Telegram as a Mini App. You open it from the bot, pick time slots, share your calendar link with people. Standard stuff on paper. In practice, I spent more time fighting the TMA SDK than implementing actual features.

The documentation is thin. The TypeScript types are incomplete. Some behaviors are platform-specific and silently do nothing. None of this is catastrophic, but every one of these cost me real time.

Here are 10 pitfalls I hit — what they look like, why they happen, and how to fix them.


1. switchInlineQuery doesn’t work on desktop

The symptom: You call WebApp.switchInlineQuery() to let users share something via inline mode. Works on mobile. On macOS and Windows Telegram clients — nothing happens. No error, no callback, just silence.

Why it happens: switchInlineQuery is part of the Bot API spec, but desktop Telegram clients simply haven’t implemented it. The method exists in the SDK, executes without throwing, and then does nothing.

The fix: Detect the platform and fall back to a share URL on desktop.

const platform = window.Telegram?.WebApp?.platform ?? "";
const isDesktop = ["macos", "tdesktop", "weba"].includes(platform);

if (isDesktop) {
  // Open a share dialog instead
  window.open(`https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`);
} else {
  WebApp.switchInlineQuery(query, ["users", "groups", "channels"]);
}

The share URL opens Telegram’s forward dialog, which works cross-platform. Not as seamless, but it actually works.


2. showConfirm callback breaks React state

The symptom: You call WebApp.showConfirm("Are you sure?", callback). Inside the callback, you call setState. The state updates — you can log it — but the component doesn’t re-render. The UI is frozen until the user does something else.

Why it happens: Telegram’s native dialogs run outside the browser’s normal execution context. When the callback fires, React’s batching mechanism doesn’t pick it up. The state change queues correctly but the re-render never gets scheduled.

The fix: Wrap your state updates in setTimeout with a zero delay. This pushes the update back into the JavaScript event loop where React’s scheduler can see it.

// Broken
WebApp.showConfirm("Delete this slot?", (confirmed) => {
  if (confirmed) setSlots([]);
});

// Fixed
WebApp.showConfirm("Delete this slot?", (confirmed) => {
  setTimeout(() => {
    if (confirmed) setSlots([]);
  }, 0);
});

Same fix applies to showAlert callbacks if you’re doing state updates there.


3. useBackButton re-registers on every render

The symptom: The Back button visually “jerks” — it flashes, re-animates, or briefly disappears — whenever the user interacts with the page. Nothing is broken functionally but it looks awful.

Why it happens: If you’re wiring up the BackButton inside a useEffect with an inline arrow function as the handler, React creates a new function reference on every render. The effect runs, calls BackButton.offClick(oldHandler) and BackButton.onClick(newHandler), triggering a re-registration cycle that Telegram renders as a visual reset.

// Broken — new function reference on every render
useEffect(() => {
  const handler = () => navigate(-1); // new ref each time
  WebApp.BackButton.onClick(handler);
  return () => WebApp.BackButton.offClick(handler);
}, [navigate]);

The fix: Stabilize the handler with useCallback.

const handleBack = useCallback(() => {
  navigate(-1);
}, [navigate]);

useEffect(() => {
  WebApp.BackButton.show();
  WebApp.BackButton.onClick(handleBack);
  return () => {
    WebApp.BackButton.offClick(handleBack);
    WebApp.BackButton.hide();
  };
}, [handleBack]);

Now the function reference is stable across renders, and the BackButton only re-registers when navigate actually changes.


4. requestFullscreen is wrong for TMA

The symptom: You call document.documentElement.requestFullscreen() to make the app fill the screen. On some platforms it works; on others the layout breaks — elements shift, the Telegram UI overlaps your content, or the viewport collapses.

Why it happens: A Telegram Mini App runs inside Telegram’s WebView frame. Calling browser-native requestFullscreen requests fullscreen for that WebView, which conflicts with how Telegram manages its own UI chrome. The result is undefined behavior that varies by platform and client version.

The fix: Use the SDK’s own expand method, which tells Telegram to give the WebView as much space as possible within its frame.

// Wrong
document.documentElement.requestFullscreen();

// Correct
WebApp.expand();

If you need to react to the expanded state, listen to WebApp.onEvent("viewportChanged", handler).


5. openInvoice and showConfirm are missing from TypeScript types

The symptom: You try to call WebApp.openInvoice() or WebApp.showConfirm(). TypeScript errors: “Property does not exist on type WebApp.”

Why it happens: The @types/telegram-web-app package is community-maintained and lags behind the actual SDK. Several methods that exist at runtime are simply absent from the type definitions.

The fix: Cast to any at the call site, or extend the type locally.

// Quick fix
(WebApp as any).openInvoice(invoiceLink, (status: string) => {
  if (status === "paid") handlePaymentSuccess();
});

// Cleaner — extend the type once in a .d.ts file
interface TelegramWebApp {
  openInvoice(url: string, callback?: (status: string) => void): void;
  showConfirm(message: string, callback: (confirmed: boolean) => void): void;
  showAlert(message: string, callback?: () => void): void;
}

Affected methods I’ve personally hit: openInvoice, showConfirm, showAlert, requestContact, requestWriteAccess. Check before assuming something doesn’t exist — it might just not be typed.


6. grammY types lag behind Bot API

The symptom: You’re processing a successful payment in your webhook handler. You try to access payment.subscription_expiration_date or payment.is_recurring — TypeScript says these properties don’t exist.

Why it happens: Telegram’s Bot API adds fields faster than the grammY type definitions get updated. The fields exist at runtime (Telegram sends them), but the TypeScript types don’t know about them yet.

The fix: Cast the payment object as any where you need to access undocumented fields.

// Accessing fields not yet in grammY types
const payment = ctx.message?.successful_payment as any;
const isRecurring = payment?.is_recurring ?? false;
const expiresAt = payment?.subscription_expiration_date;

Worth logging the full payment object on first run to see what fields actually come through, then type them explicitly in your own interface.


7. Safe area inset breaks header alignment

The symptom: Your header looks fine on most pages but is slightly misaligned on others — shifted down on some, flush on others. Not consistently wrong, just inconsistent.

Why it happens: The CSS variable --tg-content-safe-area-inset-top reflects the actual safe area, which can vary between pages depending on whether a header is shown, whether the keyboard is open, and other Telegram-managed state. If you use it directly in some places and a fixed value in others, you get inconsistency.

The fix: Use max() to always pick whichever is larger — your minimum spacing or the safe area inset.

/* Fragile — depends on inset being consistent */
padding-top: var(--tg-content-safe-area-inset-top);

/* Robust — always at least 16px, more if needed */
padding-top: max(16px, var(--tg-content-safe-area-inset-top));

Apply this pattern everywhere you use safe area insets. Inconsistency is worse than always adding a fixed padding.


8. CORS fails with Cloudflare tunnels in local dev

The symptom: Your local dev setup uses a Cloudflare tunnel (via cloudflared tunnel --url http://localhost:3000) to expose the app over HTTPS. Everything works fine in the browser. Inside Telegram, API requests fail with CORS errors.

Why it happens: Your Express server has a CORS allowlist. The tunnel URL is something like https://random-name.trycloudflare.com. You’ve added it to the allowlist, but when you restart the tunnel, the subdomain changes. Also, Telegram may send requests from a slightly different origin than you expect.

The fix: Match the entire trycloudflare.com domain with a regex check in your CORS config.

const allowedOrigins = [
  "https://yourdomain.com",
  /^https:\/\/[a-z0-9-]+\.trycloudflare\.com$/,
];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin) return callback(null, true); // allow server-to-server
    const allowed = allowedOrigins.some((o) =>
      typeof o === "string" ? o === origin : o.test(origin)
    );
    callback(allowed ? null : new Error("Not allowed by CORS"), allowed);
  },
}));

For production, remove the regex and only allow your actual domain.


9. streamText hangs in webhook context

The symptom: You integrate the Vercel AI SDK to add an AI feature to your bot. You use streamText with a model from OpenRouter. The webhook handler just… hangs. No response, no error, no timeout for a very long time. The Telegram webhook eventually times out and retries.

Why it happens: streamText opens a streaming connection and waits for chunks to arrive. In a webhook handler running in a serverless or standard Express context, that streaming connection either doesn’t drain properly or the underlying HTTP client stalls when talking to certain providers. The function never resolves.

The fix: Use generateText instead. In a bot webhook context, streaming is the wrong primitive anyway — you’re not displaying partial output to a user watching a stream, you’re generating a complete response to reply with.

// Hangs indefinitely
const stream = await streamText({
  model: openrouter("anthropic/claude-3-haiku"),
  messages,
});

// Works reliably
const result = await generateText({
  model: openrouter("anthropic/claude-3-haiku"),
  messages,
});
await ctx.reply(result.text);

If you actually want to show streaming output to the user, poll by editing the Telegram message progressively — don’t try to stream at the HTTP transport layer in a webhook handler.


10. flex-direction breaks multi-range UI

The symptom: You’re building a UI where users can add multiple time range pickers. You add the first one and it displays fine. When a second range is added, it either disappears or renders on top of the first one with overflow hidden, making it invisible.

Why it happens: The default CSS flex-direction is row. If your container wraps the range items in a flex container without explicitly setting column, the items try to sit side by side. Combined with a fixed container width and overflow: hidden, the second item is invisible.

The fix: Explicitly set flex-direction: column on the container that wraps your dynamic list items.

/* Broken — items try to stack horizontally */
.time-ranges-container {
  display: flex;
}

/* Fixed — items stack vertically as expected */
.time-ranges-container {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

This is obvious in hindsight, but it’s easy to miss when you’re testing with one item and only discover it when adding a second. Test multi-item states early.


Wrapping up

Most of these aren’t documented anywhere — you discover them at 11pm wondering why your state isn’t updating or why your button is jerking around. The TMA platform is genuinely good, but it’s also new enough that the rough edges haven’t been smoothed yet.

The ones that cost me the most: the showConfirm React batching issue (took embarrassingly long to diagnose), the streamText hang (tried everything before switching to generateText), and the desktop switchInlineQuery silent failure (because there’s no error to catch, you just assume it works).

If you’re building a Mini App, I hope this list saves you a few hours. And if you’ve hit others I missed — I’d genuinely like to know.

MeetCal is at t.me/meetcal_bot if you want to see how these pieces fit together in practice.

← arrow keys or swipe →