Recreationally overengineering my Location History
It’s been a while since I published my last #overengineering blog post. That’s not because I didn’t overengineer things, I was busier starting projects as opposed to finishing projects. Today, we shall fix this lack of content.
I like data. That’s about as surprising as the sun rising in the morning. A big part of that is visualizing various aspects of my life. I consider myself lucky enough to be able to travel quite a bit, so I always liked having a visual history of all the places I’ve been - and I’m sure lots of people can relate to that. I used to be quite happy with the Google Maps Location Timeline, but I stopped that a while ago for obvious privacy reasons. So I decided to build my own, and that’s what I’ll be talking about here. Okay, let’s go1.
The project
The main thing I wanted out of this is a map that’s showing places I have been in some visually pleasing way. This is what I ended up with:
In addition to that, I have a way to share my live location. I don’t use this very often, but occasionally I need to share my real-time location with people, for example when I’m on my way to pick someone up. The live location sharing works with only a link, so I can just drop that into a chat and make it easy to access.
To bootstrap the data, I imported my previous data from Google Maps. I have some significant gaps in there for unknown reasons, but it’s still a lot of travel history. I don’t need an exact account of where I’ve been every second, I just want to have markers whenever I’m in a new part of the world, so the location updates can be quite infrequent. Since I’m an iOS user, I created a small application that sends “significant location” updates, which allows me to collect location data with virtually no impact on battery life, but I’ll talk more about that later.
The backend
For quite some time now, I’ve been developing web projects that need backend logic in Rust. I’m not saying that you should do it, but I’m mainly not saying that because I don’t particularly like it when half of the internet is yelling at me. But for me, it’s the perfect choice. Because I’m lazy. And now, some of you are confused. I’m lazy, and yet I write my backends in Rust? Isn’t Rust much slower in getting something done?
Well yes, no, maybe. It’s true that if you’re a beginner, you probably won’t enjoy the process too much. But if you’re a slightly more intermediate Rust developer, it’s not too bad. To speed things up for me, I built a set of “project templates” that contain all the stuff I want to have. My biggest template, which I lovingly call webservice-chonk
, is a really fun mixture of my favorite components: axum
as the base, sqlx
for database access, minijinja
for server-side templating, clap
to parse CLI arguments or environment variables. It even includes a frontend asset builder based on Vite2, static file serving for those assets, ready-to-use health-check routes. It even includes a simple OpenID Connect integration, because I do most of the authentication in my personal applications using Authelia. The chonky template is a solid 1k LOC, and while that sounds like a lot, it’s not all that much for a production-grade application. If I start a new project, I can just clone the entire project and immediately start writing “business logic”. I highly recommend anyone who spends a lot of time in projects like this to build their own templates like this3.
“Production-grade” is a key here. I’m a strong believer in the “there’s no such thing as a prototype” mentality, and all my projects, even things explicitly designed to only ever be used by me, are “production-grade”. All projects have a CI setup that builds static binaries and throws them into container images. They all have health check routes that are used to alert me in case something goes wrong. And they’re all very efficient: my chonky webservice template can easily return a full response without a database request in about 100µs on my local machine, so it can easily serve 10k requests per second per connection while consuming less than 1 MiB of memory (RSS) when idling. I don’t do this just to have fancy benchmark numbers to show off. I do this because I don’t want to have to care about hosting these things. I don’t want to worry about applications falling over without me knowing. I don’t want to worry about running out of memory on my servers. I don’t want to worry about applications dying just because a few users tried to access it at once. And let’s not kid ourselves - “prototypes” always end up being deployed in production, and a few of my “oh nobody will ever use this” projects are now used in setups serving a lot of traffic. Having a solid project architecture helps with not being scared about that.
Rust plays a big role in my quest for stability, performance, and maintainability. Of course, it’s much easier to write resource-efficient Rust services as opposed to trying to run a Ruby on Rails or Go application with very little resource use. Additionally, while I’d never say that “prototyping” in Rust is as fast as, let’s say, NodeJS, the extra time I spend defining types, handling Option
s or Result
s, or just engaging in intense fights with rustc about lifetimes removes all kinds of potential sources of bugs that could annoy me in production (and it allows me to be lazy with writing tests). I can still write bad code - no doubt about that, but the extra strictness of a language like Rust saves me a ton of headaches like debugging weird NodeJS type edge-cases in the future. Rust also makes it a lot easier to analyze and improve resource use in case I do notice something weird4, which is a big plus.
Anyway, enough about Rust. For the data storage, I am using PostgreSQL with PostGIS. I have a single central Postgres instance in my infrastructure, which is really nice, because it makes backing up all data super easy. Also, since I’ll be dealing with geodata, PostGIS is pretty much a requirement - I don’t really feel like dealing with geocoordinates on my own. I tried that once and it got way too messy.
Collecting data
As mentioned, I’m an iPhone user. It was clear that this project needed some native iOS code, which was “fun” because - to be quite honest - I have no clue how Obj-C, Swift, or SwiftUI works. After working through some of the introduction courses provided by Apple, though, that went surprisingly well. I have to give it to Apple, their introduction tutorials are designed very well.
I won’t talk too much about the Swift part, because I still don’t feel like I have a full understanding of what I’m doing and I don’t want to mislead people, but in the end, I got a small app that does what I need it to do.
Doing background work in an iOS app is always a bit exciting. In case you’re not familiar with how iOS works: as soon as an app is no longer in the foreground, the operating system kills it - usually immediately. There are a few exceptions to that, like if you’re actively playing media, but in general, a background app is a not-running app. This is very annoying if you’re coming from a desktop or server context where you can just do stuff all the time. Luckily, the Apple frameworks provide ample opportunities to do stuff in the background anyway - you just have to accept that your background work happens on the operating system’s terms, not yours.
For what I am doing, Apple specifically has an API where you can tell the framework that you want to be updated for “significant location changes”. You can almost imagine this as a bit of an event system: you tell the framework that you want to receive significant location updates, and the OS wakes up your app and sends you updates whenever it feels like. Apple themselves describe it with
Apps can expect a notification as soon as the device moves 500 meters or more from its previous notification. It should not expect notifications more frequently than once every five minutes.
And yeah, that seems to be about what I’m seeing. The cool thing is that this is essentially free on your battery. The operating system schedules location updates and waking up the app when it was already doing background sync stuff anyway, so it’s not like collecting those location updates causes additional wake cycles if the phone is sleeping. Even if I’m out all day, the battery usage as reported by the system is… well… 0%. That’s really nice!
You’ll also see in my screenshot that there’s a second toggle to record something: “high-resolution updates”. The framework for that is similar, you just tell it “please give me location updates”, and the system does so. This API dispatches location updates as fast as it can and as close to real-time as it can, because this API is meant for navigation apps and similar things. I have implemented this mode for live location sharing, because I don’t want people to only see an update every few minutes. In my app, I’m throttling this to one update every three seconds, which is more than enough. Of course, this mode uses a lot of energy, which is why I’m only turning it on if I need to.
Speaking of live sharing: you’ll have seen the “Live Share Links” views. There’s very little magic here. I can generate “live share tokens”, that are just 12-byte random hexadecimal strings that get put into links. I can copy a link like https://beenthere.example.com/live/aabbccddeeff001122334455
and send that to people via a messenger or something, and anyone with that link gets live locations displayed on a map. These live tokens have a start and end timestamp so I don’t have to think about manually invalidating tokens - and I really don’t want to share my location 24/7 by accident.
Ultimately, this app ended up being just slightly above 1k lines of Swift in total, and it seems to work quite nicely.
Displaying the location history
I already shared a screenshot of how it looks earlier. Essentially, it’s a simple base map (I’m just using Mapbox here), and a hexagonal grid on top. I got some of the inspiration for that from this engineering blog post from Zalando, and I even originally planned to do a heatmap. As I played around with it, though, I realized that a heatmap isn’t giving me much value, since the density of points isn’t a great metric for anything, so I settled for a simple grid that’s just on or off.
Generating the grid was shockingly easy thanks to PostGIS. I can generate a full GeoJSON for OpenLayers to consume with a single query:
with grid as (
select cell.geom
from ST_HexagonGrid(
($max_x - $min_x) / $num_x_tiles,
ST_MakeEnvelope($min_x, $min_y, $max_x, $max_y, 3857)
) as cell
where exists (
select 1
from locations
where
source = 'sigloc'
and ST_Intersects(locations.location, ST_Transform(cell.geom, 4326))
)
)
select json_build_object(
'type', 'FeatureCollection',
'features', json_agg(ST_AsGeoJSON(grid.*, maxdecimaldigits => 6)::json)
)
from grid;
I was pleasantly surprised by how well this works. My production database has 240k “significant” location points collected over 13 years, and with an index on the locations, generating a grid for the entire world takes less than 20ms.
I calculate the number of tiles so that I always have hexagons with an edge length of 20px on screen, regardless of the window or device width. And that resolution always stays relative to the window, not the zoom level, so if I zoom further into the map, I can get fairly nice visualizations of which parts of cities I’ve been in:
And I’m quite pleased with how that works! The PostGIS grid functions do anchor their grids on some kind of geo-coordinate base, not just naively at wherever I specify the envelope, so these cells don’t jump around when I pan the map. That little UX niceness already makes PostGIS worth it.
One odd thing you might have noticed in that query: the ST_Transform
calls. Dealing with coordinate projections did cause me a bit of a headache, and I can’t do this topic justice. In short: the GPS coordinates I get from iOS and store in the database are provided in one projection (EPSG:4326
, also known as “WGS 84 / Geographic”), but OpenLayers defaults to another system (EPSG:3857
, also known as “WGS 84 / Web or Spherical Mercator”). OpenLayers provides a nice introduction if you want to learn more. And while OpenLayers has the ability to transform between projections, I found it much easier (and faster) to just do that in the database. However, be careful if you do this on your own: ST_Transform
is quite slow. In my case, I don’t want to transform all location points because that’d run for several seconds. Instead, I just transform the hex cells to match my GPS coordinate system.
Live sharing
As I already explained, I have a live location share feature with a simple link. Those links are the only piece of UI that isn’t gated behind OpenID Connect, the URL is all a watcher needs. The view looks exactly as you’d imagine, but just for visual interest, here’s a live view of the iOS Simulator driving around the Apple HQ:
The implementation of that is really just a boring WebSocket, the axum handler receiving the location updates dumps them into a tokio::sync::watch
sender, and the WebSocket connection handler receives it and throws that to still-valid clients. In axum, you handle a WebSocket by using the WebSocketUpgrade
extractor, which has an on_upgrade
method in which you specify the callback for the connection. And that callback is just a Stream - in my case, the loop {}
I used is pretty much just a tokio::select!
on a) the tokio::sync::watch
receiver, b) a tokio::time::interval
to send a ping every 30 seconds, and c) the WebSocket receiver so I can handle cases where the client sends a Ping to which I respond with a Pong.
One thing I do want to call out specifically: axum sets the default read buffer capacity to 128 KiB, and that stays allocated for the entire time the WebSocket connection stays open. So every single WebSocket client will cost you at least 128 KiB just to allocate the read buffers. Luckily, you can adjust that. In my case, I set that to 64 bytes, because I don’t really expect the client to send more than a ping. In hindsight, I maybe should have used server-sent events instead of a WebSocket. Oh well, this works well enough as-is.
In case the WebSocket connection to the client drops, I have implemented the most advanced client-side error-handling technique ever: window.location.reload()
. This has the added benefit of showing the user a nice “your token is not valid” page if the token expired, as I drop the connection if that happens. I just mention this high-tech implementation here to demonstrate that I don’t overengineer everything5.
Conclusion
It works. I’m happy with it. The server-side Rust code runs in around 750 KiB RSS idle, each watcher with an active WebSocket connection seems to use ~1 KiB of memory - which is more than good enough. The iOS app has ~1k LOC, the Rust code has ~1.8k LOC, and the frontend has ~600 lines of TypeScript and 200 lines of CSS.
This was a fun project, and it’s one of those “smaller” projects that I got done within a reasonable amount of time, without feeling like I’m writing a whole Operating System, and I know it’ll be quite a lot of pleasure to use. I already spent quite some time looking at a few big cities I’ve been to and identifying gaps in my exploration, heh.
I’ll end this post here. While I could write a lot more6, this article’s word count roughly matches the LOC count in the project, and I find that satisfying enough to just stop here.
Footnotes
-
Please do not expect a link to a full ready-to-use project at the end of the post, because it’s not gonna be there. I will share some snippets, though - and you should feel free to re-use them as you please under the MIT license. You’re also invited to reach out to me if you have questions or comments - I’m always happy when I get thoughtful feedback. ↩
-
Side Quest! If you want a bit more background on why I picked Vite and how I integrated it, you’ll find answers here. ↩
-
And I really mean the “build your own” part. While you’ll find a lot of templates online, you shouldn’t really use them in my opinion. There’s absolute value in knowing about every single piece of your projects, and if you’re just cloning someone else’s work, you lose a lot of that. I specifically will not share my templates, because they’re highly specific to what I like and need, and I don’t want to act like I’m somehow an authority on building web applications in Rust. ↩
-
Side Quest! I like
minijinja
, but I noticed something while I was profiling the memory usage of my template.minijinja
was accounting for ~20% of my idle memory footprint. A full write-up is available here. ↩ -
Fun fact: all screenshots in this post have both a light and a dark mode. They automatically switch depending on your browser or system preferences. That’s not overengineering, though, that’s just taking care of my readers’ eyes. ↩
-
Part of this project was built when my old 2023 LLM blog post turned 2 years old, so I figured it was time to re-evaluate my opinion on the usefulness of LLMs as a coding aid. I purchased a one-month subscription to “the most popular” service that allowed me to test different models - both in a chat context, but also directly integrated into my code editor. I had the LLM attempt implementing the OpenID Connect mechanism and the WebSocket server-side logic. I did that while screen-sharing in a voice call with a bunch of friends who were much more neutral in the topic of LLMs, primarily to keep my bias in check. I recorded all the sessions, and took a lot of notes. The LLMs output was so bad, I wouldn’t even call that a “clusterfuck” anymore, it was more akin to a “high-availability fuck”. This post was supposed to contain that. Ultimately, caused by the reactions I got to a mildly-viral social media post I made, I have now decided against including this - and I also decided against publishing that as its own post. The sad reality is that LLM-proponents are actively dismissing any sort of criticism, and after I got a ton of “you’re holding it wrong”-responses and multiple claims of people insinuating or actively claiming that I was “asking trick questions to make the LLM look bad”, I have lost all interest in discussing this any further. Ultimately, this project contains zero code created by an LLM, and this will also be true of future projects. ↩