DEV Community

KevinTen
KevinTen

Posted on

Building Spatial Memory: Why I Built a "Pinterest for the Physical World" and What I Learned

Building Spatial Memory: Why I Built a "Pinterest for the Physical World" and What I Learned

Honestly, I started this project on a hike last month and thought it was the dumbest best idea I'd had all year.

Let me set the scene: I'm standing at the top of this beautiful overlook, my legs are burning, I pull out my phone to snap a photo, and I think... "This view is amazing. But six months from now, when I'm scrolling through my camera roll, will I even remember where this was? And what if I could leave a note here that only people who actually show up can read?"

That's how Spatial Memory was born. The idea is simple: it's like Pinterest, but for the physical world. You "pin" memories, photos, notes to real GPS coordinates. And people can only see your pins when they're actually physically nearby. No teleporting to content. You have to show up.

I've been working on it for three weekends now, and I've learned more about spatial databases, GPS limitations, and privacy tradeoffs than I ever thought I would. So I figured I'd share what I've built, what I've learned, and where I think this idea might go.

What It Actually Does

Let me start with a quick overview. Spatial Memory is a backend API for a location-based social network where:

  • Users pin content (photos, notes, recommendations) to GPS coordinates
  • Content is only visible when you're within 50 meters of the pin
  • Users control privacy: Private / Friends-only / Public
  • Discover pins near you as you move around the world

Think of it like geocaching, but for digital memories. Or a location-based journal that others can stumble upon.

Here's the architecture I ended up with:

┌─────────────────────────────────────────────────────────────┐
│                    Client Mobile App                         │
└────────┬────────────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│                   Go Backend API                             │
│  - Authentication                                            │
│  - Spatial queries                                            │
│  - Pre-signed R2 uploads                                      │
└────────┬────────────────────────────────────────────────────┘
         │
    ┌────┴────┐        ┌───────────┐
    │PostGIS  │        │  Redis    │
    │Spatial  │        │   GEO     │
    │  Data   │        │  Cache    │
    └────┬─────┘        └───────────┘
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│               Cloudflare R2 Object Storage                    │
│              (photos, no egress fees!)                       │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

I chose Go for the backend because I wanted something lightweight that can handle many concurrent API requests without using much memory. It's been a great decision so far — the entire binary is about 12MB, and it idles at ~15MB RSS. Not bad at all.

The Interesting Part: Spatial Search with PostGIS + Redis GEO

Here's what everyone asks about: "How do you find all pins within X meters of a coordinate?"

I'll be honest with you — I'd never done any spatial programming before this project. I knew PostGIS existed, but that was about it. So I did what any developer does: I read a bunch of blog posts and messed around until it worked.

After some iteration, I landed on a two-level approach that's been working really well:

  1. Redis GEO does the first pass filtering — find all pins within radius, get their IDs
  2. PostGIS does the precise query on those candidate IDs to get full data

Let me show you the code. It's surprisingly simple.

First, when you create a new pin, you add it to both PostGIS and Redis GEO:

package service

import (
    "context"
    "github.com/go-redis/redis/v8"
    "database/sql"
    "fmt"
)

type PinService struct {
    db  *sql.DB
    rdb *redis.Client
}

func (s *PinService) CreatePin(ctx context.Context, pin *Pin) error {
    // 1. Insert into PostGIS
    _, err := s.db.ExecContext(ctx, `
        INSERT INTO pins (id, user_id, lat, lng, content, visibility, created_at)
        VALUES ($1, $2, ST_SetSRID(ST_MakePoint($3, $4), 4326), $5, $6, $7)
    `, pin.ID, pin.UserID, pin.Lat, pin.Lng, pin.Content, pin.Visibility, pin.CreatedAt)
    if err != nil {
        return fmt.Errorf("insert into postgis: %w", err)
    }

    // 2. Add to Redis GEO
    member := redis.GeoMember{
        Name:    pin.ID,
        Latitude:  pin.Lat,
        Longitude: pin.Lng,
    }
    _, err = s.rdb.GeoAdd(ctx, "pins:geo", &member).Result()
    if err != nil {
        return fmt.Errorf("add to redis geo: %w", err)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Then, when you search for nearby pins:

func (s *PinService) FindNearby(ctx context.Context, lat, lng float64, radiusMeters float64) ([]*Pin, error) {
    // Step 1: Get candidate pin IDs from Redis GEO
    results, err := s.rdb.GeoRadius(ctx, "pins:geo", lng, lat, &redis.GeoRadiusQuery{
        Radius:      radiusMeters,
        Unit:        "m",
        WithCoord:   false,
        WithDist:    false,
        WithGeoHash: false,
        Count:       100,
        Sort:        "ASC",
    }).Result()
    if err != nil {
        return nil, fmt.Errorf("redis geo radius: %w", err)
    }

    if len(results) == 0 {
        return []*Pin{}, nil
    }

    // Extract IDs from Redis results
    ids := make([]string, len(results))
    for i, res := range results {
        ids[i] = res.Name
    }

    // Step 2: Get full pin data from PostGIS with proper spatial filtering
    query := `
        SELECT id, user_id, ST_X(lat), ST_Y(lng), content, visibility, created_at
        FROM pins
        WHERE id = ANY($1)
        AND ST_DWithin(lat, ST_SetSRID(ST_MakePoint($2, $3), 4326), $4)
    `

    rows, err := s.db.QueryContext(ctx, query, ids, lng, lat, radiusMeters)
    if err != nil {
        return nil, fmt.Errorf("postgis query: %w", err)
    }
    defer rows.Close()

    var pins []*Pin
    for rows.Next() {
        var pin Pin
        err := rows.Scan(&pin.ID, &pin.UserID, &pin.Lng, &pin.Lat, &pin.Content, &pin.Visibility, &pin.CreatedAt)
        if err != nil {
            continue
        }
        pins = append(pins, &pin)
    }

    return pins, nil
}
Enter fullscreen mode Exit fullscreen mode

That's basically it. It's simple, it's fast, and it works.

What I Got Wrong: The Big Mistakes I Made

So here's the thing — I didn't get this right on the first try. Nobody does. Let me share the mistakes I made so you don't have to repeat them.

Mistake 1: I forgot about SRID projections

I know, I know. If you've done any GIS work, you're laughing at me. But hear me out — this is a mistake a lot of beginners make.

When I first created the table, I did this:

ALTER TABLE pins ADD COLUMN latlng GEOGRAPHY(Point, 4326);
Enter fullscreen mode Exit fullscreen mode

Wait, no, actually I did this first (the wrong version):

-- DON'T DO THIS if you want meters to work correctly
ADD COLUMN latlng GEOMETRY(Point, 4326);
Enter fullscreen mode Exit fullscreen mode

The difference? GEOMETRY vs GEOGRAPHY. GEOMETRY uses Cartesian coordinates (planes), GEOGRAPHY uses spherical coordinates (the actual earth shape). When you use GEOMETRY with SRID 4326, ST_DWithin distances are in degrees, not meters. So my "50 meter" radius was actually 50 degrees. That's like... half a hemisphere. Oops.

I spent an hour wondering why every query returned all my pins. Facepalm moment.

Mistake 2: GPS accuracy is garbage in cities

Okay, I knew GPS wasn't perfect. But I didn't realize how bad it can be.

Walking around downtown testing this app, I noticed that my GPS would jump 50-100 meters while I was standing still. That's because of "urban canyon" effect — the buildings reflect the GPS signals, and your phone can't get a good lock.

What does that mean for the app? If I set the visibility radius to 50 meters, you could be standing right on top of a pin and still not see it. Or you could see it when you're actually a block away.

I've been experimenting with different approaches:

  • Make the radius adjustable by the user who created the pin
  • Show "Nearby (within 200m)" instead of strict gates
  • Let you "force unlock" if you're really close but GPS says otherwise

Honestly, I still don't have a perfect solution. It's just a hardware limitation we all have to live with. If you're building a location-based app, be ready to handle this. Your users' phones are lying to you about where they are.

Mistake 3: Uploading photos directly through my backend

Initially, I thought I'd just have the client upload photos to my server, then I'd forward them to R2. That works fine for small photos, but it's terrible for UX. If you have a 5MB photo from your phone, your phone has to upload it twice — once to my API, then my API uploads it again to R2. Double the time, double the bandwidth cost.

Then I learned about pre-signed URLs for direct uploads. It's game changing. Here's how it works:

func (s *PinService) GetUploadURL(ctx context.Context, userID string, contentType string) (*UploadURL, error) {
    // Generate a unique key
    key := fmt.Sprintf("photos/%s/%s.jpg", userID, uuid.New().String())

    // Create pre-signed URL that expires in 60 seconds
    req := s.r2.PutObjectRequest(&s3.PutObjectInput{
        Bucket:      aws.String(s.bucketName),
        Key:         aws.String(key),
        ContentType: aws.String(contentType),
    })

    url, err := req.Presign(60 * time.Second)
    if err != nil {
        return nil, err
    }

    // Return URL and final public URL to client
    return &UploadURL{
        UploadURL: url,
        PublicURL: fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s/%s", s.accountID, s.bucketName, key),
        Key:       key,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The flow becomes:

  1. Client asks backend for an upload URL
  2. Backend creates pre-signed R2 URL
  3. Client uploads directly to R2
  4. Client tells backend "done, here's the public URL"

Bandwidth cost for me? Zero. Because R2 has no egress fees, and the client never goes through my server. It's perfect for side projects. I love it.

Pros and Cons of This Architecture

Let me be honest with you — this approach isn't for everyone. Here's what works and what doesn't:

✅ What Works Great

  1. It's cheap: PostGIS + Redis on a $5/month VPS, R2 for storage. You can run this for thousands of users for less than $10/month total.

  2. It's simple: No fancy distributed geospatial database. Just two tools you probably already know how to use.

  3. It's fast: With Redis doing the heavy lifting, queries return in <20ms even with thousands of pins. I haven't hit a scaling wall yet.

  4. Cloudflare R2 is amazing for photos: No egress fees. That alone saves me more than the cost of my VPS.

❌ What Doesn't Work (Yet)

  1. Scale past ~100k pins: Redis stores all GEO data in memory. 100k pins take about 3MB, which is nothing. But a million pins would be ~30MB, which is still nothing actually. So maybe it scales further than I think. We'll see.

  2. Complex spatial queries: If you need polygon intersects or fancy things like that, you're better off just using PostGIS directly. This approach is optimized for the "find everything near me" use case.

  3. Privacy is still tricky: Because pins are location-based, you can infer where people spend time. I've tried to mitigate this by letting users make anything private, but it's something to think about if you're launching something similar.

Privacy: I Did This Differently

One thing I'm pretty proud of is the privacy model. A lot of location-based apps collect every where you go and sell that data. That's gross. I wanted to build something different.

Here's what I do:

  • Progressive visibility: Pins default to private. The user can choose: only me / only friends / everyone.
  • No location tracking: I don't store your location history. The server only sees your location when you actively do a search. I don't keep it.
  • Data ownership: You can delete your content any time, and it's gone from everywhere — database, Redis, R2. Done.
  • No third-party analytics: No Google Analytics, no Facebook pixel, nothing. I don't even have logs that keep user IPs longer than an hour for debugging.

Call me old-fashioned, but I think if you're building a service around people's memories, you owe them that much.

The Real Problem: Cold Start

Here's the brutal truth about this idea: it's useless until there are enough pins in enough places. It's a classic chicken-and-egg problem. You need users to create pins, but users don't have a reason to come back until there are pins they can discover.

Does that mean the idea is dead? I don't think so. I built this as a side project, not a startup. I'm not trying to raise money or get millions of users. I just think the idea is cool, and I wanted to see if it could work.

I've been adding pins around my city when I go on walks. My friends have started adding theirs. It's fun to go explore and see what pops up. Maybe it stays that small, maybe it grows. Either way, I've learned a ton building it, so it's already a success in my book.

What's Next

I'm planning to build the mobile frontend next weekend using Flutter. Right now I just have a simple CLI tester and the API. I want to actually use this when I'm hiking.

Some things I want to explore:

  • Offline caching of nearby pins so it works when you don't have cell service
  • AR previews where you can see pins pinned on the camera view (this seems really cool)
  • Collections / categories of pins (waterfalls, good coffee, secret viewpoints, etc.)
  • Social features — follow people whose pins you like

If you're interested in following along or trying it out when it's ready, star the repo on GitHub. I'll keep pushing updates there.

Questions For You

I'm curious — what do you think about this idea? Have you ever wanted an app like this to leave notes for future you (or other people) in physical places? Would you actually use it, or is it just a cool idea that doesn't solve a real problem?

And if you've built any location-based apps before — what mistakes did you make that I should watch out for? I'm still learning this whole spatial programming thing, so I'd love to hear your experiences in the comments.

The full code is open source here: https://github.com/kevinten10/spatial-memory — go take a look if you're curious. Constructive PRs are welcome!

Top comments (0)