Back to blog
FILE 0x1B·A PERSONAL LOCATION HEATMAP ON CHEAP LAMBDA INFRASTRUCTURE

A personal location heatmap on cheap Lambda infrastructure

April 16, 2026 · homelab, aws, maps

I had years of GPS pings in a database from a side project. I wanted to look at where I'd actually been on a map — heatmap of every ping, markers for the addresses I visited most, a pulsing dot for "right now." All self-hosted, on infrastructure that costs nothing per month if I'm not hitting it.

What was happening

Roughly 32,000 GPS pings sitting in a database with nothing visualizing them. The default reaction is "spin up a small EC2." The honest answer is that this is a single read-heavy endpoint that takes a few seconds to render and gets hit maybe a couple times a week. It doesn't need a server. It needs a function.

What I found

The right shape was a single Python Lambda behind API Gateway, returning either an HTML page (Leaflet + heatmap plugin from a CDN) or a JSON blob (/data). One Lambda. One function. One IAM role. The page polls /data on load, renders the heatmap client-side. ACM cert in us-east-1 for the custom domain, Route 53 alias to the API Gateway target. Total moving parts: about five.

There's a small AWS gotcha that pushed me from Lambda Function URLs to API Gateway: in some accounts, Lambda Function URLs return 403 even when the resource policy is correct. I never tracked down why — could be an SCP, could be an account-level setting — but the fix was to use API Gateway HTTP API instead, which "just worked." Lesson: if Function URLs misbehave in your account, don't fight it; switch to API Gateway.

The fix

The handler is small:

import json, os, boto3
from collections import Counter

ddb = boto3.client("dynamodb")
USER_ID = int(os.environ.get("USER_ID", "1"))
_cache = {"data": None, "expires_at": 0}

def handler(event, context):
    path = event.get("rawPath", "/")
    if path == "/data":
        return {"statusCode": 200,
                "headers": {"content-type": "application/json",
                            "access-control-allow-origin": "*"},
                "body": json.dumps(get_data())}
    return {"statusCode": 200,
            "headers": {"content-type": "text/html; charset=utf-8"},
            "body": HTML_PAGE}

def get_data():
    now = time.time()
    if _cache["data"] and _cache["expires_at"] > now:
        return _cache["data"]
    # query DDB for this user's pings, aggregate top spots,
    # find current position from the most-recent row
    ...
    _cache["data"] = result
    _cache["expires_at"] = now + 60
    return result

The HTML page is a tiny Leaflet template that loads the heatmap plugin from unpkg and renders the /data JSON. The dark CARTO tile set looks better than the default OSM tiles for this kind of dataset. The whole page is about 80 lines including style.

The JSON payload is ~1.5 MB for 32k pings. That's small enough that I didn't bother server-side clustering. If it grew an order of magnitude, I'd thin the points server-side or precompute a clustered representation.

What I'd do differently

Two things:

For a one-off personal visualization, this topology is hard to beat. Total monthly cost: a few cents in Lambda + Route53.