Back to all posts
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:
| Tier | Cache Time | Example |
|---|---|---|
| Static | 24 hours | Whiskey catalog, distillery info |
| Semi-dynamic | 5 minutes | User profiles, post counts |
| Real-time | No cache | Notifications, 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:
- Performance: Bun is fast. Really fast. Cold starts are nearly instant.
- Developer Experience: Elysia's type inference is magical. Define your schema once, get types everywhere.
- 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.