Cross-Platform Without Compromise: How We Build Fluid Mobile Experiences

The question is never "native or cross-platform." The question is "what does the user feel?"

When users tap your app, they are not thinking about your tech stack. They notice one thing: whether it feels right. A stutter on scroll, a tap that takes 80ms too long to respond, an animation that doesn't track their finger — these are not bugs. They are broken promises.

At Brewstack Labs, our mobile work runs on React Native (with Expo) and Flutter. Both can produce native-feel apps. Both can produce sluggish ones. The difference is architecture.


The Performance Budget

Every screen has a performance budget before a line of code is written.

  • 60fps minimum. Anything below feels broken on modern hardware.
  • < 300ms to first meaningful interaction on cold launch.
  • < 16ms per frame budget on the JS thread.
  • Zero janks on scroll — non-negotiable.

These are not aspirational targets. They are pass/fail gates in our CI pipeline using Flashlight (React Native) and Flutter's DevTools performance overlay.


React Native Architecture

The New Architecture is the Baseline

We build on the React Native New Architecture (JSI + Fabric + TurboModules) exclusively. The bridge-based architecture is no longer an option for production apps.

JSI (JavaScript Interface) eliminates the async serialization cost of the old bridge. Native calls are synchronous and direct.

// TurboModule — direct synchronous native call via JSI
// No serialization, no message queue, no callback round-trip
import { NativeModules } from 'react-native';

interface BiometricModule {
  authenticate(reason: string): Promise<{ success: boolean; error?: string }>;
  isAvailable(): boolean; // synchronous — only possible with JSI
}

const { BiometricAuth } = NativeModules as { BiometricAuth: BiometricModule };

// This runs synchronously on the JS thread — no async round-trip to native
const available = BiometricAuth.isAvailable();

Keeping the JS Thread Clear

The JS thread handles your React rendering tree. Block it and the entire UI freezes. The rule: no heavy computation on the JS thread.

import { runOnJS, runOnWorklet } from 'react-native-reanimated';

// Reanimated worklets run on the UI thread, not the JS thread
// Gesture tracking, scroll animations, and haptics happen here
const gestureHandler = useAnimatedGestureHandler({
  onActive: (event, ctx) => {
    'worklet'; // this function runs natively, not in JS
    translateX.value = ctx.startX + event.translationX;
    translateY.value = ctx.startY + event.translationY;
  },
  onEnd: () => {
    'worklet';
    translateX.value = withSpring(0, { damping: 15, stiffness: 120 });
    translateY.value = withSpring(0, { damping: 15, stiffness: 120 });
  },
});

Heavy operations (image processing, crypto, data transformation) move to a background thread via react-native-quick-base64, react-native-mmkv's synchronous storage, or a dedicated native module.

We use Expo Router (file-based routing on top of React Navigation). The routing structure mirrors the Next.js App Router pattern — consistent mental model across web and mobile codebases.

app/
  _layout.tsx          # Root layout with auth gate
  (auth)/
    login.tsx
    register.tsx
  (app)/
    _layout.tsx        # Tab navigator
    index.tsx          # Home screen
    projects/
      index.tsx        # Project list
      [id].tsx         # Project detail
  +not-found.tsx

Deep links, universal links, and push notification routing all resolve through the same file-based system. No manual linking config.


Flutter Architecture

Riverpod as the State Layer

Flutter's widget tree is a rendering engine, not a state manager. Riverpod separates state from UI cleanly.

// Provider definition — outside any widget, globally accessible
final projectListProvider = FutureProvider.autoDispose<List<Project>>((ref) async {
  final repo = ref.read(projectRepositoryProvider);
  return repo.getProjects();
});

// Widget — reads state, never manages it
class ProjectListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(projectListProvider);

    return state.when(
      loading: () => const ProjectListSkeleton(),
      error: (err, _) => ErrorState(message: err.toString()),
      data: (projects) => ProjectGrid(projects: projects),
    );
  }
}

autoDispose ensures providers are garbage-collected when no widget is listening. No memory leaks from stale state.

Custom Render Objects for Performance-Critical UI

When standard widgets are not fast enough, we drop down to RenderObject directly.

// Custom render object for a high-frequency animation
// Bypasses the widget rebuild cycle entirely
class WaveformRenderer extends LeafRenderObjectWidget {
  final List<double> amplitudes;
  final Color color;

  const WaveformRenderer({ required this.amplitudes, required this.color });

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _WaveformRenderBox(amplitudes: amplitudes, color: color);
  }

  @override
  void updateRenderObject(BuildContext context, _WaveformRenderBox renderObject) {
    renderObject
      ..amplitudes = amplitudes
      ..color = color;
  }
}

class _WaveformRenderBox extends RenderBox {
  List<double> amplitudes;
  Color color;

  _WaveformRenderBox({ required this.amplitudes, required this.color });

  @override
  void paint(PaintingContext context, Offset offset) {
    final paint = Paint()..color = color..strokeWidth = 2.0;
    // Direct canvas drawing — no widget overhead
    for (var i = 0; i < amplitudes.length; i++) {
      final x = offset.dx + i * 4.0;
      final mid = offset.dy + size.height / 2;
      context.canvas.drawLine(
        Offset(x, mid - amplitudes[i] * size.height / 2),
        Offset(x, mid + amplitudes[i] * size.height / 2),
        paint,
      );
    }
  }
}

Offline-First Data Layer

Mobile users go offline. The app should not notice.

React Native: MMKV + WatermelonDB

import { MMKVLoader } from 'react-native-mmkv-storage';

// MMKV — synchronous key-value store, 10x faster than AsyncStorage
// Used for session data, user preferences, feature flags
const storage = new MMKVLoader().initialize();

storage.setString('session.token', token);       // synchronous write
const token = storage.getString('session.token'); // synchronous read

For relational data (projects, tasks, user records), WatermelonDB gives us a lazy-loading SQLite layer with full offline support and built-in sync adapters.

import { synchronize } from '@nozbe/watermelondb/sync';

async function syncWithServer() {
  await synchronize({
    database,
    pullChanges: async ({ lastPulledAt }) => {
      const { data } = await api.get('/sync/pull', { params: { since: lastPulledAt } });
      return data;
    },
    pushChanges: async ({ changes }) => {
      await api.post('/sync/push', changes);
    },
  });
}

The device is the source of truth. Cloud sync is eventual and conflict-aware.


Haptics as a Design Language

Haptic feedback is not decoration. It is the tactile confirmation that the UI registered a user's intent.

import * as Haptics from 'expo-haptics';

// Selection feedback — light, for navigation and item selection
await Haptics.selectionAsync();

// Impact feedback — medium weight, for primary actions
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);

// Notification feedback — for success, warning, error states
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);

We pair every significant state transition with a haptic. Users feel the app working before they see it.


Shared Business Logic Across Platforms

Web (Next.js) and mobile (React Native / Flutter) do not share UI. They do share logic.

All business rules, validation schemas, and API clients live in packages/core — a TypeScript package imported by both.

packages/
  core/
    src/
      schemas/         # Zod validation schemas
      api/             # Type-safe API client
      utils/           # Date formatting, currency, etc.
      types/           # Shared TypeScript types
apps/
  web/                 # Next.js
  mobile/              # Expo / React Native

A validation rule defined once in packages/core/schemas runs identically on the Next.js server, in a React Native form, and in a Flutter app via dart_mappings.


The Delivery Standard

Before any mobile build ships to a device:

  1. Flashlight CI runs a performance audit — FPS, JS thread usage, TTI
  2. Detox runs end-to-end tests on a real device emulator
  3. Bundle size is checked — no dependency added without a conscious size budget review
  4. Accessibility audit — all interactive elements have correct labels and minimum 44pt touch targets

Shipping a mobile app is a promise to a user's device. We keep it.

Talk To The
Brew Team.

I'M A  [SELECT ONE]

ABOUT YOU

YOUR PROJECT