Location-Based Search at Scale: MongoDB Geospatial Queries for Marketplace Apps
When building RentFox, our peer-to-peer rental marketplace, we knew location-based search would make or break the user experience. Nobody wants to rent a power drill that's 50 miles away when their neighbor has one sitting in their garage.
We needed sub-100ms query times for "show me all listings within 10 miles of my location" - even with hundreds of thousands of listings in the database.
Here's how we achieved it with MongoDB's geospatial indexes, and why it's faster than you might think.
The Problem: Proximity Search at Scale
Traditional database queries are great for exact matches or simple comparisons. But asking "which of these 500,000 listings are within 10 miles of latitude 34.0522, longitude -118.2437" is a different beast entirely.
The naive approach? Calculate the distance to every single listing:
// ❌ DON'T DO THIS - O(n) complexity
const listings = await Listing.find({});
const nearby = listings.filter(listing => {
const distance = calculateDistance(
userLat, userLng,
listing.latitude, listing.longitude
);
return distance <= 10; // miles
});
This works fine with 100 listings. With 100,000? Your users will be waiting 5+ seconds for results.
The Solution: MongoDB 2dsphere Indexes
MongoDB has built-in support for geospatial queries using 2dsphere indexes. These indexes use a spherical geometry model (because Earth is round, not flat - sorry flat-earthers) to enable lightning-fast proximity searches.
Step 1: Schema Design
First, structure your data with a location field using GeoJSON format:
// models/Listing.js
const listingSchema = new mongoose.Schema({
title: { type: String, required: true },
description: String,
price: { type: Number, required: true },
category: String,
// GeoJSON location field - THIS IS THE KEY
location: {
type: {
type: String,
enum: ['Point'], // Only Point type for now
required: true
},
coordinates: {
type: [Number], // [longitude, latitude] - ORDER MATTERS!
required: true
}
},
// Optional: Store human-readable address separately
address: {
street: String,
city: String,
state: String,
zipCode: String,
country: String
},
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
createdAt: { type: Date, default: Date.now }
});
// Create 2dsphere index - THIS ENABLES FAST QUERIES
listingSchema.index({ location: '2dsphere' });
module.exports = mongoose.model('Listing', listingSchema);
Critical Detail: GeoJSON coordinates are [longitude, latitude] - NOT [latitude, longitude]. This trips up everyone at first (including us).
Step 2: Creating Listings with Location Data
When users create listings, convert their address or GPS coordinates to GeoJSON:
// controllers/listing.controller.js
const createListing = async (req, res) => {
try {
const { title, description, price, category, latitude, longitude, address } = req.body;
// Validate coordinates
if (!latitude || !longitude) {
return res.status(400).json({ error: 'Location is required' });
}
if (latitude < -90 || latitude > 90) {
return res.status(400).json({ error: 'Invalid latitude' });
}
if (longitude < -180 || longitude > 180) {
return res.status(400).json({ error: 'Invalid longitude' });
}
const listing = new Listing({
title,
description,
price,
category,
location: {
type: 'Point',
coordinates: [longitude, latitude] // REMEMBER: [lng, lat]
},
address,
owner: req.user._id
});
await listing.save();
res.status(201).json({ success: true, listing });
} catch (error) {
console.error('Create listing error:', error);
res.status(500).json({ error: 'Failed to create listing' });
}
};
Step 3: Proximity Search with $geoNear
Now the magic happens. MongoDB's $geoNear aggregation stage finds listings near a point and calculates distances in a single query:
// controllers/listing.controller.js
const searchNearby = async (req, res) => {
try {
const {
latitude,
longitude,
maxDistance = 10, // miles
category,
minPrice,
maxPrice,
page = 1,
limit = 20
} = req.query;
// Validate required coordinates
if (!latitude || !longitude) {
return res.status(400).json({ error: 'Location is required' });
}
// Convert miles to meters (MongoDB uses meters)
const maxDistanceMeters = parseFloat(maxDistance) * 1609.34;
// Build aggregation pipeline
const pipeline = [
// Stage 1: Find nearby listings with $geoNear
{
$geoNear: {
near: {
type: 'Point',
coordinates: [parseFloat(longitude), parseFloat(latitude)]
},
distanceField: 'distance', // Calculated distance in meters
maxDistance: maxDistanceMeters,
spherical: true, // Use spherical geometry (Earth is round!)
key: 'location' // Index to use
}
},
// Stage 2: Filter by category (if provided)
...(category ? [{ $match: { category } }] : []),
// Stage 3: Filter by price range (if provided)
...(minPrice || maxPrice ? [{
$match: {
...(minPrice && { price: { $gte: parseFloat(minPrice) } }),
...(maxPrice && { price: { $lte: parseFloat(maxPrice) } })
}
}] : []),
// Stage 4: Convert distance from meters to miles
{
$addFields: {
distanceMiles: {
$round: [{ $divide: ['$distance', 1609.34] }, 2]
}
}
},
// Stage 5: Populate owner details
{
$lookup: {
from: 'users',
localField: 'owner',
foreignField: '_id',
as: 'ownerDetails'
}
},
{
$unwind: '$ownerDetails'
},
// Stage 6: Project only needed fields
{
$project: {
title: 1,
description: 1,
price: 1,
category: 1,
images: 1,
distance: 1,
distanceMiles: 1,
'ownerDetails.name': 1,
'ownerDetails.trustScore': 1,
'ownerDetails.avatar': 1,
createdAt: 1
}
},
// Stage 7: Pagination
{ $skip: (parseInt(page) - 1) * parseInt(limit) },
{ $limit: parseInt(limit) }
];
// Execute query
const listings = await Listing.aggregate(pipeline);
// Get total count for pagination
const countPipeline = pipeline.slice(0, -2); // Remove skip/limit
const totalDocs = await Listing.aggregate([
...countPipeline,
{ $count: 'total' }
]);
const total = totalDocs[0]?.total || 0;
const hasMore = (parseInt(page) * parseInt(limit)) < total;
res.json({
success: true,
listings,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
hasMore,
totalPages: Math.ceil(total / parseInt(limit))
}
});
} catch (error) {
console.error('Search nearby error:', error);
res.status(500).json({ error: 'Failed to search listings' });
}
};
Understanding the Math: Converting Units
MongoDB works in meters, but users think in miles (or kilometers). Here's the conversion:
// Distance conversions
const METERS_PER_MILE = 1609.34;
const METERS_PER_KILOMETER = 1000;
// Convert miles to meters (for query)
const maxDistanceMeters = miles * METERS_PER_MILE;
// Convert meters to miles (for display)
const distanceMiles = meters / METERS_PER_MILE;
// For kilometers instead
const maxDistanceMeters = kilometers * METERS_PER_KILOMETER;
const distanceKilometers = meters / METERS_PER_KILOMETER;
MongoDB also supports radians, but we find meters more intuitive for conversions.
Performance Optimization
Creating the Index
Make sure the 2dsphere index is actually created:
// Run this once during database setup
db.listings.createIndex({ location: '2dsphere' });
// Verify index exists
db.listings.getIndexes();
// Output should include:
// {
// "v": 2,
// "key": { "location": "2dsphere" },
// "name": "location_2dsphere"
// }
Without the index, queries will do a full collection scan - death by a thousand database reads.
Compound Indexes for Common Queries
If you frequently filter by category AND location, create a compound index:
listingSchema.index({ category: 1, location: '2dsphere' });
This enables MongoDB to filter by category first, then search nearby - significantly faster for category-specific searches.
Query Benchmarking
We benchmarked our geospatial queries with 500,000 listings:
| Query Type | Without Index | With 2dsphere Index |
|---|---|---|
| 10 mile radius | 4,200ms | 42ms |
| 5 mile radius | 3,800ms | 28ms |
| 1 mile radius | 3,500ms | 15ms |
The index provides 100x speedup for typical proximity queries.
Real-World Implementation: RentFox Search Flow
Here's how users search for listings in RentFox:
- User opens app → Request location permission
-
Get GPS coordinates →
(34.0522, -118.2437)(Los Angeles) - User searches → "power tools within 10 miles"
-
Backend query →
$geoNearwith category filter - Results → Sorted by distance, showing "2.3 miles away", "5.7 miles away"
The entire flow from search to results: < 100ms.
Edge Cases We Handle
1. User Denies Location Permission
// Fallback to ZIP code search
const { zipCode } = req.query;
// Use a geocoding service to convert ZIP → coordinates
const coords = await geocodeZipCode(zipCode);
// Then proceed with $geoNear as normal
2. International Listings (Different Units)
// Detect user's country and convert units
const userCountry = req.user.country;
const distanceDisplay = userCountry === 'US'
? `${distanceMiles} mi`
: `${distanceKilometers} km`;
3. Very Remote Areas (No Results)
// If no results within maxDistance, suggest expanding search
if (listings.length === 0) {
return res.json({
success: true,
listings: [],
suggestion: 'No results found. Try expanding your search radius.'
});
}
Alternative Approaches (And Why We Didn't Use Them)
PostgreSQL with PostGIS
PostgreSQL's PostGIS extension is powerful for geospatial data, but:
- Requires additional setup and expertise
- RentFox already uses MongoDB for other features
- MongoDB's 2dsphere is "good enough" for our use case
When to use PostGIS: Complex spatial operations (polygon intersections, route planning, multi-geometry queries).
Redis Geo
Redis has GEOADD and GEORADIUS commands for fast proximity searches:
GEOADD listings -118.2437 34.0522 listing:123
GEORADIUS listings -118.2437 34.0522 10 mi
Pros: Blazing fast (< 5ms queries)
Cons: In-memory only, no filtering/sorting, separate from main database
When to use Redis Geo: Real-time location tracking (Uber-style driver locations), simple radius queries without filters.
Haversine Formula (JavaScript)
You could calculate distances in application code:
// Haversine formula implementation
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 3958.8; // Earth radius in miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
Why we don't use this: O(n) complexity. Every listing must be checked. MongoDB's index does this in O(log n).
Production Checklist
Before deploying geospatial search to production:
- [x] Create 2dsphere index on
locationfield - [x] Validate all coordinates on input (latitude: -90 to 90, longitude: -180 to 180)
- [x] Use GeoJSON format with
[longitude, latitude]order - [x] Convert miles ↔ meters correctly
- [x] Handle edge cases (no location permission, no results, international units)
- [x] Add monitoring for slow queries (> 200ms)
- [x] Test with production-scale data (100k+ listings)
- [x] Implement pagination for large result sets
- [x] Cache popular searches (e.g., "Los Angeles" queries)
Join the RentFox Waitlist 🦊
We're launching RentFox in early 2026, and the waitlist is now live! Experience lightning-fast location-based search yourself and connect with neighbors in your area.
Why join early?
- Get notified the moment we launch in your area
- Exclusive early-access features and benefits
- Be part of the community shaping the future of peer-to-peer rentals
- See our MongoDB geospatial queries in action with real-world data!
👉 Join the waitlist at revolvo.tech/rentfox 👈
The signup takes 30 seconds, and we'll notify you as soon as we're live in your city. Plus, your location data is handled securely with the same MongoDB infrastructure we just described!
What's Next
In our next article, we'll explore gamifying trust with a 6-level reputation system. We'll dive into the algorithm that combines verifications, reviews, and behavior to create Bronze, Silver, Gold, Platinum, and Diamond trust scores.
Want to see more deep dives into marketplace architecture? Follow along as we build RentFox and share every technical decision, optimization, and lesson learned.
Building a location-based app and need help with geospatial queries? Drop us a line - we love talking database optimization and sharing what we've learned.
Already signed up for the RentFox waitlist? Share this article with backend developers who need to implement proximity search!
Top comments (0)