Why the App Did Not Call Public Data Directly
How slow collection, prediction, and notification preparation moved into server workers so the screen could stay light.
As the iOS app and widgets split into several screens and touchpoints, the backend took on the job of preparing external data in advance so the app could read it immediately.
Early on, I thought the app could call public-data APIs directly. In a small view, that is simpler. The app calls the parking data API and displays the response.
That approach was attractive. The server stays thin, the first screen can be built quickly, and when something breaks, the code is right in front of you. But the data Hangangjari handles comes from different sources and moves at different speeds. If the app absorbs all of those differences, the screen can be built quickly, but users see slow sources and empty responses directly.
As features grew, the limits of direct app calls became clear.
Each external source had different field names, update methods, and status expressions. Some data changed often, while some changed only once a day. When an external source was slow or failed, the app still needed to carry the last verified value, updated time, and source status together.
Prediction was better suited to the server as well. Hourly parking changes and park congestion changes needed accumulated data and background jobs. Notifications were similar. To notify users of status changes or congestion signals when they did not open the app, the server had to handle subscriptions and APNs delivery.
For that reason, the backend used FastAPI, and I separated request-serving APIs from workers that prepare values in the background.
flowchart LR
Sources["Official public sources<br/>Parking · Events · Facilities · Real-time signals"] --> Workers["Workers<br/>Collect · Normalize · Validate"]
Workers --> PG[("PostgreSQL / PostGIS<br/>Reference data · History · Location")]
Workers --> Redis[("Redis<br/>Current state · Forecast summaries")]
PG --> Forecast["Forecasts<br/>Hourly changes"]
Forecast --> Redis
PG --> Notify["Notification candidates<br/>Condition changes"]
Redis --> Notify
Notify --> APNs["APNs"]
PG --> API["FastAPI<br/>Prepared responses"]
Redis --> API
API --> Client["iOS app · Widgets"]
APNs --> Client
In this setup, the API focuses on reading as much as possible. I did not put external scraping, new prediction generation, or notification-candidate calculation inside the user request path. If external sources are slow or fail when a user opens the app, that slowness and failure would appear directly on the screen.
The reason for leaving only reads in the API was simple. I wanted to separate the user request path from data preparation. Collection and normalization can slow down. Predictions can be rebuilt on a schedule. Notification candidates need condition checks. But app and widget responses should be short and stable.
The current backend is roughly split into these roles.
- API: responses the app and widgets read immediately
- Parking worker: parking-lot list and current status collection
- Outing worker: event, facility, notice, and real-time city-data collection
- Forecast worker: parking and park congestion change calculation
- Notification worker: selecting notification candidates and sending them through APNs
Data is stored in PostgreSQL/PostGIS, while frequently read state and forecast responses are cached in Redis. Postgres keeps reference data and history, and Redis keeps short-lived responses that can be read quickly.
This distinction became more important in operation. When I separate what can be regenerated from what must be retained, I can narrow down whether to check the API, workers, or source data first during an incident.
The API only reads
The app creates screens that can be checked quickly. The server normalizes differences between external data sources, caches values, builds forecasts, and creates notification candidates. The database preserves reference data and history, while Redis delivers short-lived current state quickly.
This shape was not fixed from the beginning. Early on, the boundary between API and batch jobs was blurrier. But once I thought about operation, collection, prediction, notification, and API response had to move at different speeds. I separated workers and started looking at metrics and health checks by role.
In a public-data app, a source failure can easily look like an app failure. So rather than simply hiding failures, it was important to carry the last successful value, updated time, and source status together. What users need is not a promise of an always-correct present, but the most honest value that can be trusted right now.
Prediction followed the same logic. A forecast is not a promise to predict the future. It is more like a supporting signal that helps users understand hourly changes and make a better decision. Forecast values therefore had to stay separate from current values, with different cache lifetimes and update paths.
Even for a personal project, “the code runs” and “this can be operated” are different problems.
Work that does not happen during a request
At first, I thought the API could fetch the needed data, transform it, and return it during the request. For a small app, that can be fast to build. But Hangangjari receives requests at the moment users decide to move. If an external source is slow or fails at that moment, the app slows down too and the widget looks empty.
So I removed several jobs from the API path.
- Calling external sources directly.
- Slow normalization and coordinate correction.
- Rebuilding predictions.
- Calculating notification candidates.
- Repeatedly retrying failed sources.
Workers handle these jobs periodically, and the API reads already-prepared values quickly. The API answers user requests; workers prepare values behind the scenes. They slow down for different reasons and require different checks.
After this split, failure diagnosis also changed. I could distinguish whether the API was slow, whether a worker failed to prepare a value, or whether a source was delayed.
Stale values have to say they are stale
In a public-data app, failures cannot simply be hidden. If a last successful value exists, it can still be useful. But it must not look current. Hangangjari treats stale values not merely as failures, but as states that the screen has to explain.
For example, a parking count from five minutes ago can be useful. A value from forty minutes ago requires caution. Event information may be fine after a few hours, but real-time parking information is different. Each data type has its own acceptable freshness.
This difference has to be present in the API response for the screen to be honest. The screen does not receive only a “current value.” It also needs to know how old the value is, when the last success happened, and whether the source is currently failing. That is how the widget and app avoid misleading users.
Prediction should not replace the current value
Parking prediction is attractive at first. “Tell me when spaces will be available” sounds powerful. But forecasts cannot replace actual parking conditions. Events, weather, controls, and unexpected incidents cannot all be predicted.
So prediction sits one level below the current value. Remaining spaces and updated time come first, and prediction helps users understand hourly change. When deciding whether to go right now, the current value is central. When asking whether it might be better to go later, prediction helps.
With that distinction, the screen does not speak about prediction as certainty. Prediction is not a feature that claims to be right. It is a feature that makes an uncertain mobility decision a little less anxious.
The server’s job was not to make a big conclusion for the user. It was to avoid passing the slowness and gaps of external data directly to the user, and to prepare values that are honest enough to trust right now.
Share
No comments yet. You can leave the first one.
Pending review