DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

Building Cross-Platform Apps: Flutter vs React Native vs Tauri vs Electron — The Definitive Comparison

Building Cross-Platform Apps: Flutter vs React Native vs Tauri vs Electron — The Definitive Comparison

If you've ever had to ship the same app on iOS, Android, Windows, macOS, and the web, you know the pain. Five codebases, five sets of bugs, five platform-specific quirks that make you question your career choices.

Cross-platform frameworks promise to fix that. But which one actually delivers? Let's cut through the hype and get into the real engineering trade-offs.


What Is Cross-Platform Development and Why Should You Care?

Cross-platform development means writing one codebase (or mostly one) that runs on multiple platforms — mobile, desktop, web, or all three.

The business case is pretty straightforward:

Factor Native (per platform) Cross-Platform
Codebase count 1 per platform (2-5) 1 shared (+ platform-specific bits)
Dev team size Separate iOS, Android, desktop teams One team, shared skills
Time to market Slow — parallel development Faster — build once, deploy everywhere
UI consistency Platform-native by default Consistent across platforms
Maintenance cost High — N codebases to update Lower — single source of truth
Performance Best possible Good to excellent (depends on framework)

The catch? "Write once, run anywhere" has always been a spectrum, not a binary. Every framework makes different trade-offs between code sharing, performance, and native platform fidelity. Understanding those trade-offs is what this guide is about.


The Landscape in 2026

Here's how the major players break down:

┌─────────────────────────────────────────────────────────────────┐
│                    CROSS-PLATFORM LANDSCAPE                     │
├─────────────────┬──────────────────┬────────────────────────────┤
│     MOBILE      │     DESKTOP      │       MOBILE + DESKTOP     │
├─────────────────┼──────────────────┼────────────────────────────┤
│ Flutter         │ Tauri            │ Flutter (mobile + desktop) │
│ React Native    │ Electron         │ React Native + desktop     │
│ Kotlin Multi-   │ .NET MAUI        │ Kotlin Multiplatform +     │
│   platform      │                  │   Compose Multiplatform    │
├─────────────────┴──────────────────┴────────────────────────────┤
│                          WEB (PWA)                              │
│            Progressive Web Apps — sometimes enough              │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Let's go deep on each one.


Flutter — Google's UI Toolkit

Flutter is Google's open-source framework for building natively compiled applications from a single codebase. It targets mobile, web, desktop, and embedded devices.

How Flutter Actually Works

Unlike React Native, Flutter doesn't use native platform UI components. Instead, it brings its own rendering engine and draws every single pixel itself.

┌──────────────────────────────────────────┐
│              YOUR DART CODE              │
│         (Widgets, Business Logic)        │
├──────────────────────────────────────────┤
│            FLUTTER FRAMEWORK             │
│   (Material, Cupertino, Widget Tree)     │
├──────────────────────────────────────────┤
│            FLUTTER ENGINE                │
│     (Impeller / Skia Rendering,          │
│      Dart Runtime, Platform Channels)    │
├──────────────────────────────────────────┤
│           PLATFORM EMBEDDER              │
│  (iOS Shell, Android Shell, Windows...)  │
└──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key architectural points:

  • Impeller (the successor to Skia) is Flutter's rendering engine. It pre-compiles all shaders during build time, which eliminates shader compilation jank — a massive improvement over the Skia days.
  • Dart compiles to native ARM/x64 code on mobile and desktop (AOT), and to JavaScript/WebAssembly for web.
  • Platform Channels let you call native APIs (Swift/Kotlin) when you need platform-specific functionality.

The Widget System

Everything in Flutter is a widget. Seriously, everything. Your app? A widget. A button? A widget. Padding? Also a widget.

import 'package:flutter/material.dart';

class UserProfile extends StatelessWidget {
  final String name;
  final String email;
  final String avatarUrl;

  const UserProfile({
    super.key,
    required this.name,
    required this.email,
    required this.avatarUrl,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      margin: const EdgeInsets.all(16),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Row(
          children: [
            CircleAvatar(
              radius: 36,
              backgroundImage: NetworkImage(avatarUrl),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    name,
                    style: Theme.of(context).textTheme.titleLarge,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    email,
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Colors.grey[600],
                    ),
                  ),
                ],
              ),
            ),
            IconButton(
              icon: const Icon(Icons.edit),
              onPressed: () => _editProfile(context),
            ),
          ],
        ),
      ),
    );
  }

  void _editProfile(BuildContext context) {
    // Navigate to edit screen
  }
}
Enter fullscreen mode Exit fullscreen mode

State Management with Riverpod

Flutter has a ton of state management options. Riverpod has become the community favorite for good reason — it's compile-safe, testable, and doesn't depend on BuildContext.

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Define a provider for fetching user data
final userProvider = FutureProvider.autoDispose
    .family<User, String>((ref, userId) async {
  final repository = ref.watch(userRepositoryProvider);
  return repository.getUser(userId);
});

// Use it in a widget
class UserScreen extends ConsumerWidget {
  final String userId;

  const UserScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider(userId));

    return userAsync.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (err, stack) => Center(child: Text('Error: $err')),
      data: (user) => UserProfile(
        name: user.name,
        email: user.email,
        avatarUrl: user.avatarUrl,
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Platform Channels — Talking to Native Code

When you need to access a platform API that Flutter doesn't wrap:

// Dart side
import 'package:flutter/services.dart';

class BatteryService {
  static const _channel = MethodChannel('com.myapp/battery');

  Future<int> getBatteryLevel() async {
    final int level = await _channel.invokeMethod('getBatteryLevel');
    return level;
  }
}
Enter fullscreen mode Exit fullscreen mode
// Android side (Kotlin)
class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.myapp/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                if (call.method == "getBatteryLevel") {
                    val batteryLevel = getBatteryLevel()
                    result.success(batteryLevel)
                } else {
                    result.notImplemented()
                }
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

Flutter: Pros and Cons

Pros Cons
Pixel-perfect consistency across platforms Dart isn't as popular as JS/TS — smaller talent pool
Impeller rendering = smooth 60/120fps Doesn't use native UI components (can feel "off" to purists)
Hot reload is genuinely fast Web support is functional but not ideal for content-heavy sites
Strong desktop support (Windows, macOS, Linux) Large app size compared to native (~5-15MB overhead)
Massive widget library Platform channels required for advanced native features
Growing ecosystem, backed by Google Two-widget-system (Material + Cupertino) can be confusing

React Native — Meta's Bridge to Native

React Native lets you build mobile apps using React and JavaScript/TypeScript. Unlike Flutter, it renders using actual native platform components.

The New Architecture (2024+)

React Native underwent a massive architectural overhaul. The old bridge-based system was a bottleneck — every JS-to-native call was serialized as JSON and sent across an async bridge. The new architecture fixes this.

┌─────────────────────────────────────────────────────────────┐
│    OLD ARCHITECTURE              NEW ARCHITECTURE           │
│                                                             │
│  ┌──────────┐                 ┌──────────┐                  │
│  │    JS    │                 │    JS    │                  │
│  │  Thread  │                 │  Thread  │                  │
│  └────┬─────┘                 └────┬─────┘                  │
│       │                            │                        │
│  ┌────▼─────┐                 ┌────▼─────┐                  │
│  │  Bridge  │  ← async,      │   JSI    │  ← sync,        │
│  │  (JSON)  │    batched      │ (C++ IF) │    direct        │
│  └────┬─────┘                 └────┬─────┘                  │
│       │                            │                        │
│  ┌────▼─────┐            ┌────────▼────────────┐           │
│  │  Native  │            │  Fabric   │  Turbo   │           │
│  │ Modules  │            │(Renderer) │ Modules  │           │
│  └──────────┘            └──────────────────────┘           │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

JSI (JavaScript Interface): A C++ layer that lets JavaScript directly call native functions without serialization. This is the backbone of the new architecture.

Fabric: The new rendering system. It can render UI synchronously when needed (critical for gestures and animations) and supports concurrent features from React 18+.

TurboModules: Native modules that are lazily loaded — they only initialize when first accessed, improving startup time.

Expo — The Recommended Way to Build

Expo has evolved from a "limited but easy" option to the officially recommended way to build React Native apps. It handles builds, OTA updates, native module configuration, and more.

# Create a new Expo project
npx create-expo-app@latest MyApp
cd MyApp

# Start development
npx expo start
Enter fullscreen mode Exit fullscreen mode
// app/(tabs)/index.tsx — Expo Router file-based routing
import { StyleSheet, View, Text, FlatList } from 'react-native';
import { useQuery } from '@tanstack/react-query';

interface Post {
  id: number;
  title: string;
  body: string;
}

export default function HomeScreen() {
  const { data: posts, isLoading, error } = useQuery<Post[]>({
    queryKey: ['posts'],
    queryFn: () =>
      fetch('https://jsonplaceholder.typicode.com/posts')
        .then(res => res.json()),
  });

  if (isLoading) return <Text style={styles.loading}>Loading...</Text>;
  if (error) return <Text style={styles.error}>Error loading posts</Text>;

  return (
    <View style={styles.container}>
      <FlatList
        data={posts}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={styles.card}>
            <Text style={styles.title}>{item.title}</Text>
            <Text style={styles.body} numberOfLines={2}>
              {item.body}
            </Text>
          </View>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f5f5f5' },
  loading: { textAlign: 'center', marginTop: 50 },
  error: { textAlign: 'center', marginTop: 50, color: 'red' },
  card: {
    backgroundColor: 'white',
    marginHorizontal: 16,
    marginVertical: 8,
    padding: 16,
    borderRadius: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  title: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
  body: { fontSize: 14, color: '#666' },
});
Enter fullscreen mode Exit fullscreen mode

Native Modules with TurboModules

When you need to access native APIs:

// specs/NativeBattery.ts — TurboModule spec
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  getBatteryLevel(): Promise<number>;
  addBatteryListener(callback: (level: number) => void): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeBattery');
Enter fullscreen mode Exit fullscreen mode

React Native: Pros and Cons

Pros Cons
Huge ecosystem (npm + React) Performance ceiling lower than Flutter for heavy animations
Uses actual native UI components Debugging can be painful (JS + native stack traces)
React skills transfer directly Upgrading major versions historically painful
Expo handles most complexity Desktop support exists but less mature
Strong TypeScript support "Bridgeless" migration can be complex for large apps
OTA updates via Expo/CodePush Native module setup still requires platform knowledge

Tauri — The Lightweight Desktop Contender

Tauri is an open-source framework for building desktop (and now mobile) apps using web technologies for the frontend and Rust for the backend. Think of it as Electron, but without shipping an entire Chromium browser.

How Tauri Works

┌─────────────────────────────────────────────────────┐
│                    YOUR TAURI APP                    │
│                                                     │
│  ┌─────────────────────────────────────────────┐    │
│  │           FRONTEND (Web Tech)               │    │
│  │    HTML / CSS / JS (React, Svelte, Vue...)  │    │
│  │    Rendered by OS WebView:                  │    │
│  │      macOS → WebKit (WKWebView)             │    │
│  │      Windows → WebView2 (Edge/Chromium)     │    │
│  │      Linux → WebKitGTK                      │    │
│  └──────────────────┬──────────────────────────┘    │
│                     │ IPC (JSON-RPC)                 │
│  ┌──────────────────▼──────────────────────────┐    │
│  │            BACKEND (Rust Core)              │    │
│  │    Commands, File I/O, System APIs,         │    │
│  │    Plugins, Security Policies               │    │
│  └─────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The big idea: instead of bundling Chromium (~150MB), Tauri uses the OS's built-in webview. Your app binary can be as small as 2-5MB.

Tauri in Practice — Rust Commands

// src-tauri/src/lib.rs
use serde::{Deserialize, Serialize};
use tauri::command;

#[derive(Debug, Serialize, Deserialize)]
struct Document {
    id: String,
    title: String,
    content: String,
    modified_at: String,
}

#[command]
async fn read_documents(directory: String) -> Result<Vec<Document>, String> {
    let mut documents = Vec::new();
    let entries = std::fs::read_dir(&directory)
        .map_err(|e| format!("Failed to read directory: {}", e))?;

    for entry in entries {
        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
        let path = entry.path();

        if path.extension().map_or(false, |ext| ext == "md") {
            let content = std::fs::read_to_string(&path)
                .map_err(|e| format!("Failed to read file: {}", e))?;
            let metadata = std::fs::metadata(&path)
                .map_err(|e| format!("Failed to read metadata: {}", e))?;

            documents.push(Document {
                id: path.file_stem().unwrap().to_string_lossy().to_string(),
                title: path.file_name().unwrap().to_string_lossy().to_string(),
                content,
                modified_at: format!("{:?}", metadata.modified().unwrap()),
            });
        }
    }

    Ok(documents)
}

#[command]
async fn save_document(path: String, content: String) -> Result<(), String> {
    std::fs::write(&path, &content)
        .map_err(|e| format!("Failed to save: {}", e))?;
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            read_documents,
            save_document
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode
// Frontend — calling Rust commands from JS
import { invoke } from '@tauri-apps/api/core';

interface Document {
  id: string;
  title: string;
  content: string;
  modified_at: string;
}

async function loadDocuments(): Promise<Document[]> {
  try {
    const docs = await invoke<Document[]>('read_documents', {
      directory: '/Users/me/Documents/notes',
    });
    return docs;
  } catch (error) {
    console.error('Failed to load documents:', error);
    return [];
  }
}

async function saveDocument(path: string, content: string): Promise<void> {
  await invoke('save_document', { path, content });
}
Enter fullscreen mode Exit fullscreen mode

Tauri's Security Model

Tauri takes security seriously. By default, everything is locked down. You explicitly allowlist which APIs your frontend can access:

// src-tauri/capabilities/default.json
{
  "identifier": "default",
  "description": "Default permissions for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "dialog:allow-save",
    "fs:allow-read",
    "fs:allow-write",
    "fs:scope-home"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This is a huge advantage over Electron, where the Node.js backend has full system access by default and security is opt-in rather than opt-out.

Tauri: Pros and Cons

Pros Cons
Tiny bundle size (2-5MB vs 150MB+ Electron) Rust has a steep learning curve
Low memory footprint WebView inconsistencies across OS (especially Linux)
Strong security model (allowlist-based) Smaller ecosystem than Electron
Use any web framework for UI Mobile support (Tauri v2) is still maturing
Rust backend = fast + memory safe No built-in Chromium means no Chrome DevTools Protocol
Active community, rapid development Some web APIs unavailable in native webviews

Electron — The 800-Pound Gorilla

Electron packages Chromium and Node.js together, letting you build desktop apps with web technologies. It's been around since 2013 (originally Atom Shell) and powers some of the most widely-used desktop apps on the planet.

How Electron Works

┌────────────────────────────────────────────────────┐
│                  ELECTRON APP                      │
│                                                    │
│  ┌──────────────────┐    ┌──────────────────────┐  │
│  │   Main Process   │    │  Renderer Process(es) │  │
│  │   (Node.js)      │◄──►│  (Chromium)           │  │
│  │                  │IPC │                       │  │
│  │ - System APIs    │    │ - Your web UI         │  │
│  │ - File system    │    │ - HTML/CSS/JS         │  │
│  │ - Native menus   │    │ - React/Vue/etc.      │  │
│  │ - Tray icons     │    │ - One per window      │  │
│  └──────────────────┘    └──────────────────────┘  │
│                                                    │
│  ┌────────────────────────────────────────────────┐ │
│  │     Bundled Chromium + Node.js Runtime         │ │
│  │              (~150-300MB)                       │ │
│  └────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Apps Built with Electron

Electron has a proven track record:

  • VS Code — Microsoft's code editor (arguably the best advertisement for Electron)
  • Discord — Chat/voice application
  • Slack — Team communication
  • Figma Desktop — Design tool
  • Notion — Productivity workspace
  • 1Password (legacy) — Password manager (they've since moved to Tauri/Rust)

When Electron Makes Sense

Electron gets a bad rap for memory usage, and that criticism is fair. But it makes sense when:

  1. You have a large web team and need a desktop app fast
  2. Chrome DevTools compatibility is important (e.g., you need Chrome extensions to work)
  3. Consistent rendering across platforms matters more than resource usage
  4. The app is already a web app and you're wrapping it for desktop distribution

The Resource Elephant in the Room

Let's be honest about the numbers:

Metric Electron Tauri Native
Empty app bundle ~150MB ~3MB ~5-10MB
Idle RAM ~80-150MB ~20-40MB ~10-30MB
Startup time 1-3s 0.3-0.8s 0.1-0.5s
Chromium included Yes (full) No (uses OS webview) N/A

VS Code manages to be great despite Electron's overhead because Microsoft invested heavily in performance optimization. Most teams don't have those resources.


Kotlin Multiplatform — Shared Logic, Native UI

Kotlin Multiplatform (KMP) takes a different approach: instead of sharing the UI, you share the business logic and let each platform render its own native UI.

┌──────────────────────────────────────────────────────┐
│              KOTLIN MULTIPLATFORM                     │
│                                                      │
│  ┌──────────────────────────────────────────────┐    │
│  │            SHARED CODE (Kotlin)              │    │
│  │  - Business logic, data models, networking   │    │
│  │  - Repository layer, use cases               │    │
│  │  - Ktor (HTTP), SQLDelight (DB),             │    │
│  │    kotlinx.serialization                     │    │
│  └────────┬──────────────┬──────────────┬───────┘    │
│           │              │              │            │
│  ┌────────▼─────┐ ┌─────▼──────┐ ┌─────▼──────┐    │
│  │  Android UI  │ │   iOS UI   │ │ Desktop UI │    │
│  │  (Jetpack    │ │  (SwiftUI  │ │ (Compose   │    │
│  │   Compose)   │ │   / UIKit) │ │  Desktop)  │    │
│  └──────────────┘ └────────────┘ └────────────┘    │
└──────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
// shared/src/commonMain/kotlin/com/myapp/data/UserRepository.kt
class UserRepository(
    private val api: UserApi,
    private val db: UserDatabase
) {
    suspend fun getUser(id: String): User {
        // Try cache first
        val cached = db.getUserById(id)
        if (cached != null && !cached.isStale()) {
            return cached
        }

        // Fetch from network
        val user = api.fetchUser(id)
        db.insertUser(user)
        return user
    }

    fun observeUsers(): Flow<List<User>> = db.observeAllUsers()
}
Enter fullscreen mode Exit fullscreen mode

With Compose Multiplatform, JetBrains extends this to shared UI as well — you can write Jetpack Compose code that runs on Android, iOS, desktop, and web. It's still maturing on iOS, but the trajectory is promising.

KMP: Best For

  • Teams that are already Kotlin-heavy
  • Apps where platform-native UI is non-negotiable
  • Sharing 50-70% of code (business logic) while keeping UI native
  • Companies invested in the JetBrains/Android ecosystem

PWAs — When a Web App Is Enough

Before reaching for a framework, ask: do you actually need a native app?

Progressive Web Apps can:

  • Work offline (Service Workers)
  • Be installed on the home screen
  • Send push notifications (yes, even on iOS now)
  • Access cameras, geolocation, sensors
  • Run in the background
// service-worker.js — basic offline caching
const CACHE_NAME = 'app-v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(ASSETS))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cached => cached || fetch(event.request))
  );
});
Enter fullscreen mode Exit fullscreen mode

When a PWA is enough: Content apps, dashboards, internal tools, e-commerce, news apps, simple utilities.

When you need native: Heavy animations/games, Bluetooth/NFC, background processing, deep OS integration, App Store presence is critical.


Head-to-Head Comparison

Here's the big comparison table everyone wants:

Factor Flutter React Native Tauri Electron KMP
Language Dart JS/TS Rust + Web JS/TS + Node Kotlin
Mobile Excellent Excellent Maturing (v2) No Excellent
Desktop Good Experimental Excellent Excellent Good
Web OK (canvas) Limited N/A N/A Maturing
Performance Excellent Good Excellent Acceptable Excellent
Bundle Size Medium (~15MB) Medium (~10MB) Tiny (~3MB) Huge (~150MB) Small (native)
Native Feel Custom (pixel-perfect) Native components Web in webview Web in Chromium Truly native UI
Dev Speed Fast (hot reload) Fast (fast refresh) Medium Fast Medium
Ecosystem Growing (pub.dev) Massive (npm) Growing (crates.io) Massive (npm) Growing
Learning Curve Medium (Dart) Low (if you know React) High (Rust) Low Medium (Kotlin)
Hiring Moderate Easy Hard (Rust devs) Easy Moderate
Backed By Google Meta Community + CrabNebula OpenJS Foundation JetBrains + Google

What Companies Use What

Real-world adoption tells you a lot:

Company Framework Why
Google (Google Pay, Google Ads) Flutter Dogfooding their own framework, pixel-perfect UI
Meta (Facebook, Instagram) React Native Built it, uses it at scale
Shopify (Shop app) React Native Leveraged large web/React team
BMW Flutter Consistent in-car + mobile UI
Microsoft (VS Code) Electron Maximum web ecosystem compatibility
1Password Tauri/Rust Moved from Electron for security + perf
Discord Electron → Custom Started Electron, investing in native
Netflix (internal tools) KMP Shared business logic across platforms
Nubank Flutter Fast iteration, consistent UI
Alibaba Flutter High-performance e-commerce UI

Decision Framework

Use this flowchart to narrow things down:

START: What platforms do you need?
│
├─ Mobile only (iOS + Android)?
│  ├─ Team knows React/JS? ──────────► React Native + Expo
│  ├─ Need pixel-perfect custom UI? ─► Flutter
│  ├─ Team knows Kotlin? ────────────► Kotlin Multiplatform
│  └─ It's a content/CRUD app? ──────► Consider PWA first
│
├─ Desktop only (Win + Mac + Linux)?
│  ├─ Performance/security critical? ─► Tauri
│  ├─ Large web team, ship fast? ─────► Electron
│  └─ .NET team? ────────────────────► .NET MAUI
│
├─ Mobile + Desktop?
│  ├─ One UI everywhere? ────────────► Flutter
│  ├─ Share logic, native UI? ───────► Kotlin Multiplatform
│  └─ Separate mobile + desktop? ────► React Native + Tauri
│
└─ Need web too?
   ├─ Web is primary, apps are bonus?► PWA
   ├─ Same UI on all platforms? ─────► Flutter (with caveats on web)
   └─ Web + Mobile? ────────────────► React Native for Web + Mobile
Enter fullscreen mode Exit fullscreen mode

Quick Rules of Thumb

  1. "I need a mobile app and my team knows React" → React Native with Expo
  2. "I need beautiful custom UI on mobile and desktop" → Flutter
  3. "I need a lightweight desktop app" → Tauri
  4. "I need to wrap my web app for desktop distribution" → Electron (or Tauri if you can add Rust)
  5. "I need native-quality UI and I'm a Kotlin shop" → KMP + Compose Multiplatform
  6. "I need something that works offline with push notifications" → Try a PWA first

Common Mistakes to Avoid

1. Picking Based on Hype

Framework popularity cycles move fast. "X is the future!" changes every 18 months. Pick based on your team's skills, your project's actual requirements, and the framework's maturity for your target platforms.

2. Underestimating Platform Nuances

Cross-platform doesn't mean "ignore the platform." iOS users expect a certain navigation pattern. Android users expect the back button to work. macOS users expect Cmd+C. Windows users expect a system tray. You still need to understand each platform's conventions.

3. Assuming "Cross-Platform" Means "Zero Platform-Specific Code"

Realistically, you'll still need 10-25% platform-specific code for things like:

  • Push notification setup
  • Deep linking configuration
  • App Store / Play Store requirements
  • Platform-specific permissions
  • OS-specific UI polish

4. Not Prototyping the Hard Parts First

Before committing, build a prototype of your most complex feature — not a todo app. If your app needs Bluetooth, build the Bluetooth part first. If it needs heavy animations, prototype those. The easy parts are easy in every framework.

5. Ignoring the Build & CI/CD Story

Some frameworks have smoother CI/CD stories than others. Expo's EAS Build is turnkey. Flutter's builds are straightforward. Tauri on CI requires Rust toolchain setup. Factor this into your timeline.


Wrapping Up

There's no single "best" cross-platform framework. There's the best one for your team, your project, and your constraints. Here's my honest take:

  • Flutter is the most versatile — it covers the most platforms with the best quality.
  • React Native is the most pragmatic — it leverages the largest developer community and ecosystem.
  • Tauri is the most efficient — if you're building for desktop and can handle Rust, nothing beats it.
  • Electron is the most proven — it has warts, but it ships products at scale.
  • KMP is the best compromise — share logic, keep native UI, especially if you're in the Kotlin ecosystem.

The key isn't finding the perfect framework. It's understanding the trade-offs well enough to make an informed choice — and then committing to it and building something great.


Let's Connect!

If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on system design, backend engineering, and software architecture.

Connect with me on LinkedIn — let's grow together.

Drop a comment, share this with someone who needs this, and follow along for more guides like this!

Top comments (0)