The Problem
A food delivery platform needed three interconnected applications: a customer app for ordering food, a vendor portal for restaurants to manage menus and orders, and a backend API to orchestrate everything. The technical twist: the existing backend was built in Ruby on Rails with production authentication bugs, requiring a mid-project migration to Node.js while maintaining zero downtime for live orders.
What Made It Hard
Ruby on Rails to Node.js Migration
The existing Rails backend had:
- OTP authentication endpoints with race conditions
- Session management causing intermittent login failures
- Email verification flows breaking under load
- Production incidents every week
Challenge: Migrate authentication/OTP endpoints to Node.js without breaking existing features or requiring frontend changes.
Migration Constraints:
- Frontend already deployed (can't change API contracts)
- Database schema can't change (shared with Rails)
- Must maintain session compatibility
- Zero downtime requirement
Location-Based Ordering with Google Maps
Customers order from restaurants based on proximity:
- User enters delivery address
- System shows only restaurants within delivery radius
- Distance calculations for delivery fees
- Restaurant search with map view
- Geolocation for "near me" searches
Complexity:
- Haversine distance calculations
- Radius filtering in real-time
- Handling inaccurate GPS coordinates
- Displaying 100+ restaurants on map without lag
Discount Scheduling Engine
Restaurants needed time-based discount automation:
- "20% off Monday-Friday 2-4 PM"
- "Buy 2 get 1 free on weekends"
- "Free delivery after 10 PM"
- Promo codes with usage limits
Challenge: Building a scheduling system that evaluates discounts in real-time as user adds items to cart, respecting timezone, date ranges, and business rules.
My Solution
Rails-to-Node Migration Strategy
Implemented strangler fig pattern (incremental replacement):
Phase 1: Identify Problem Endpoints
- Audited production logs
- Found race conditions in OTP generation/verification
- Session token issues causing 401 errors
Phase 2: Build Node.js Equivalent
// New Node.js endpoint
app.post('/api/auth/send-otp', async (req, res) => {
const { phone } = req.body;
// Generate OTP with atomic transaction
const otp = await db.transaction(async (t) => {
// Invalidate previous OTPs
await OTP.update(
{ status: 'expired' },
{ where: { phone, status: 'active' }, transaction: t }
);
// Create new OTP
return await OTP.create({
phone,
code: generateOTP(),
expiresAt: Date.now() + 5 * 60 * 1000,
status: 'active'
}, { transaction: t });
});
// Send SMS
await sendSMS(phone, otp.code);
res.json({ success: true });
});
Phase 3: Dual-Run Testing
- Node endpoints ran in parallel with Rails
- Logged differences in responses
- Fixed edge cases before cutover
Phase 4: Traffic Switchover
- Nginx routing rules to direct auth traffic to Node
- Rails endpoints remained for other features
- Monitored error rates (dropped by 95%)
Result: Zero production incidents after migration, authentication bugs resolved.
Location-Based Restaurant Discovery
Built geolocation-powered ordering system:
1. Address Input with Autocomplete
- Google Places API for address suggestions
- Geocoding to get lat/lng coordinates
- User can also drag map pin for precision
2. Radius-Based Filtering
function getRestaurantsInRadius(
userLat: number,
userLng: number,
radiusKm: number = 5
): Restaurant[] {
// Haversine formula for distance
const restaurants = await db.query(`
SELECT *,
(6371 * acos(
cos(radians(:userLat)) * cos(radians(latitude)) *
cos(radians(longitude) - radians(:userLng)) +
sin(radians(:userLat)) * sin(radians(latitude))
)) AS distance
FROM restaurants
HAVING distance < :radiusKm
ORDER BY distance ASC
`, { userLat, userLng, radiusKm });
return restaurants;
}
3. Map View with Clustering
- Google Maps integration
- Cluster markers when zoomed out (performance)
- Click marker to see restaurant details
- Smooth animations for pin placement
4. Delivery Fee Calculation
- Based on distance from restaurant
- Dynamic pricing (0-3km = free, 3-5km = $2, 5km+ = $4)
- Shown before checkout
Discount Scheduling Engine
Built rule-based discount system:
interface DiscountRule {
name: string;
type: 'percentage' | 'fixed' | 'bogo';
value: number;
schedule: {
daysOfWeek: number[]; // [1,2,3,4,5] = Mon-Fri
startTime: string; // "14:00"
endTime: string; // "16:00"
};
conditions: {
minOrderValue?: number;
maxUsagePerUser?: number;
validUntil?: Date;
};
}
function evaluateDiscounts(
cart: CartItem[],
user: User,
restaurant: Restaurant,
orderTime: Date
): Discount[] {
const applicableDiscounts = restaurant.discounts.filter(discount => {
// Check day of week
const dayOfWeek = orderTime.getDay();
if (!discount.schedule.daysOfWeek.includes(dayOfWeek)) {
return false;
}
// Check time range
const currentTime = orderTime.getHours() * 60 + orderTime.getMinutes();
const [startH, startM] = discount.schedule.startTime.split(':').map(Number);
const [endH, endM] = discount.schedule.endTime.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
if (currentTime < startMinutes || currentTime > endMinutes) {
return false;
}
// Check min order value
const orderTotal = cart.reduce((sum, item) => sum + item.price, 0);
if (discount.conditions.minOrderValue && orderTotal < discount.conditions.minOrderValue) {
return false;
}
// Check usage limit
if (discount.conditions.maxUsagePerUser) {
const usageCount = await getUserDiscountUsage(user.id, discount.id);
if (usageCount >= discount.conditions.maxUsagePerUser) {
return false;
}
}
return true;
});
return applicableDiscounts;
}
UI Features:
- Real-time discount badge on cart ("20% off applied!")
- Strikethrough original price
- Countdown timer for time-limited discounts
- Promo code input with validation
Branch & Menu Management for Vendors
Built vendor portal with:
- Branch Management: Multiple locations per restaurant
- Menu CRUD: Categories, items, modifiers (extra cheese, no onions)
- Availability Toggles: Mark items out of stock
- Pricing: Base price + modifier pricing
- Image Uploads: Item photos with preview
Challenge: Keeping menus in sync across branches
- Solution: Template system (copy menu to new branch)
- Branch-specific overrides (price adjustments)
Technical Decisions
Why Node.js over Keeping Rails?
- Team expertise (JavaScript full-stack)
- Better concurrency for real-time features
- Shared language with frontend (code reuse)
- Rails OTP race conditions couldn't be fixed (async issues)
Why Material-UI over Custom Components?
- Faster development (pre-built components)
- Consistent design language
- Accessibility out-of-the-box
- Customizable theming
Why Google Maps over Mapbox?
- Better Places API (address autocomplete)
- Familiar UX for customers
- Accurate distance calculations
- Client already had Google Cloud account
Why Real-Time Polling over WebSockets?
- Simpler implementation
- Better for mobile (WebSocket drops on network switch)
- 10-second polling acceptable for order tracking
- Less server overhead (no persistent connections)
Impact
Business Metrics
- 3 portals delivered (customer, vendor, backend)
- 95% reduction in authentication errors post-migration
- Location-based ordering enabled for 100+ restaurants
- Real-time discount engine increased promotion usage by 40%
Technical Achievements
- Zero-downtime migration from Rails to Node.js
- Sub-500ms geolocation queries with Haversine
- Google Maps integration with clustering for performance
- Discount evaluation runs in under 50ms per cart update
Developer Experience
- Consolidated codebase (JavaScript frontend + backend)
- Reusable geolocation utilities across projects
- Automated tests prevent discount calculation bugs
- Vendor portal reduces customer support tickets by 30%
What I'd Do Differently
Complete Rails Migration
Would migrate entire backend to Node.js:
- Current hybrid (Node for auth, Rails for orders) causes deployment complexity
- Maintaining two backends doubles infrastructure cost
- Team context-switching between languages
- Should have allocated time for full migration
PostGIS for Geolocation
Would use PostgreSQL PostGIS extension:
- Current Haversine in application code is slow
- PostGIS indexes spatial queries (10x faster)
- Built-in radius queries and polygon support
- Better for map-heavy features
Redis for Discount Caching
Would cache discount evaluations in Redis:
- Current approach recalculates on every cart update
- Same discounts evaluated repeatedly
- Cache with TTL = time until discount expires
- Huge performance gain
Automated Migration Testing
Would implement shadow traffic testing:
- Send production traffic to both Rails and Node
- Compare responses automatically
- Catch regressions before cutover
- Current manual testing was time-consuming
Key Takeaways
- Strangler fig pattern works for incremental migrations - replace one feature at a time
- Location features need spatial databases - application-level distance calculations don't scale
- Time-based scheduling is hard - timezone, daylight saving, and edge cases multiply fast
- Vendor portals reduce support load - self-service beats manual menu updates
- Migration without downtime is possible - dual-run, compare, cutover, rollback plan