2025-08-19 overengineering, software
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.