Noah Kurz

Back to all posts

React NativeArchitectureTypeScriptMobile Development

Building Scalable React Native Apps: Lessons from NEAT

Key architectural decisions and patterns I learned while building a social platform serving thousands of users with React Native, Bun, and Elysia.

N

Noah Kurz

December 10, 2024

8 min read

Building a mobile app that serves thousands of users taught me more about software architecture than any tutorial ever could. In this post, I'll share the key lessons I learned while building NEAT, a social platform for whiskey enthusiasts.

The Challenge

When I started NEAT, I knew it would need to handle:

  • User-generated content at scale (1,000+ posts and growing)
  • Real-time social interactions like follows, likes, and comments
  • Geolocation features for finding nearby whiskey
  • Complex database relationships across 25+ interconnected tables

This wasn't going to be a simple CRUD app.

Architecture Decisions That Mattered

1. Type-Safe API Communication

One of the best decisions I made was implementing tRPC-like communication between the client and server. This gave us:

// Shared types between client and server
interface CreatePostInput {
  content: string
  whiskey_id: string
  location?: {
    lat: number
    lng: number
  }
}
 
// Type-safe API call on the client
const { mutate } = useMutation({
  mutationFn: (input: CreatePostInput) => api.posts.create(input),
})

No more runtime type errors from API mismatches. If the server contract changes, TypeScript catches it immediately.

2. Optimistic Updates for Perceived Performance

Social apps need to feel instant. Nobody wants to wait for a server response to see their like register. We implemented optimistic updates across all social interactions:

const likeMutation = useMutation({
  mutationFn: (postId: string) => api.posts.like(postId),
  onMutate: async (postId) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['post', postId] })
    
    // Snapshot current state
    const previousPost = queryClient.getQueryData(['post', postId])
    
    // Optimistically update
    queryClient.setQueryData(['post', postId], (old) => ({
      ...old,
      liked: true,
      likeCount: old.likeCount + 1,
    }))
    
    return { previousPost }
  },
  onError: (err, postId, context) => {
    // Rollback on error
    queryClient.setQueryData(['post', postId], context.previousPost)
  },
})

3. Strategic Data Fetching

Not all data is created equal. We categorized our data into three tiers:

TierCache TimeExample
Static24 hoursWhiskey catalog, distillery info
Semi-dynamic5 minutesUser profiles, post counts
Real-timeNo cacheNotifications, chat messages

This dramatically reduced server load while keeping the app feeling fresh.

The Backend Stack

After experimenting with several options, I landed on Bun + Elysia for the backend. Here's why:

  1. Performance: Bun is fast. Really fast. Cold starts are nearly instant.
  2. Developer Experience: Elysia's type inference is magical. Define your schema once, get types everywhere.
  3. Ecosystem: Full compatibility with the npm ecosystem meant no compromise on tooling.
// Elysia route with full type inference
app.post('/posts', async ({ body, user }) => {
  const post = await db.insert(posts).values({
    ...body,
    author_id: user.id,
    created_at: new Date(),
  }).returning()
  
  return post
}, {
  body: t.Object({
    content: t.String({ minLength: 1, maxLength: 500 }),
    whiskey_id: t.String(),
  }),
})

Lessons Learned

Start Simple, Then Optimize

My initial database schema was over-normalized. I had separate tables for everything because "that's the right way." In practice, this led to:

  • Complex queries with 5+ joins
  • Slow read performance
  • Difficult debugging

We eventually denormalized some frequently-accessed data, accepting minor data duplication for major performance gains.

Invest in Developer Experience

Setting up proper tooling early paid dividends:

  • TypeScript strict mode from day one
  • Automated testing for critical paths
  • Consistent code formatting with Prettier
  • Pre-commit hooks to catch issues early

The upfront cost was worth it. Onboarding new code paths became predictable.

Monitor Everything

We integrated error tracking and performance monitoring from the start. When issues arose in production, we had the data to diagnose quickly.

"You can't improve what you don't measure."

What's Next

Building NEAT taught me that scalable apps aren't about using the "right" technology—they're about making pragmatic decisions based on real constraints.

The stack matters less than understanding your users' needs and optimizing for them.


Have questions about building mobile apps at scale? Feel free to reach out on LinkedIn or Twitter.