A personal location heatmap on cheap Lambda infrastructure
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:
- I'd build a Function URL first and only fall back to API Gateway when one of them returned 403. In a new account, Function URLs are the simpler path — one fewer resource, no custom domain dance to do.
- The 60-second in-Lambda cache is a hack. With one or two viewers it's fine; if anyone else ever uses this, the right answer is a small pre-aggregated JSON object in S3, updated by a separate cron Lambda, and the API just serves it. Static-first, dynamic-second.
For a one-off personal visualization, this topology is hard to beat. Total monthly cost: a few cents in Lambda + Route53.