DEV Community

Revolvo Tech
Revolvo Tech

Posted on

Location-Based Search at Scale: MongoDB Geospatial Queries for Marketplace Apps

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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' });
  }
};
Enter fullscreen mode Exit fullscreen mode

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' });
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
// }
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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:

  1. User opens app → Request location permission
  2. Get GPS coordinates(34.0522, -118.2437) (Los Angeles)
  3. User searches → "power tools within 10 miles"
  4. Backend query$geoNear with category filter
  5. 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
Enter fullscreen mode Exit fullscreen mode

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`;
Enter fullscreen mode Exit fullscreen mode

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.'
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 location field
  • [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)