Skip to content

Sourav Ukil

Writing

Hire me
All writing

Computing prayer times offline

heybarakah has to be right in a tunnel, on a plane, and on a phone that has not seen a network all day. So it does the astronomy itself.

Sourav Ukil15 min

The obvious way to build a prayer app is to call an API. There are several good ones. They are cheap or free, they are maintained by people who care about correctness, and you can have the whole thing working in a weekend. I started there. Then I took it out, which cost about three weeks, and it was the right call.

The reason is a sentence I wrote down early and kept coming back to: this app fails at exactly the moment it is needed, and at exactly the moment it is least able to reach the network. Underground on a train. On a flight. In a basement office. In a building with thick walls and a meeting about to start. An app showing a spinner at maghrib is not a slow app. It is a broken one, and no amount of caching fully rescues you, because caches go stale in precisely the direction that matters.

You can mitigate. Fetch a year ahead, store it, refresh opportunistically. That works until the person travels, which is when the times change most and the network is least reliable. It also means shipping a product whose correctness depends on something that happened weeks ago, somewhere else, which is not a thing I wanted to be responsible for.

So the times are computed on the device. This is not novel work. It is well documented astronomy that has been implemented correctly many times over. It is simply work most people skip, because an API exists and skipping it is free.

The core of it is solar position. Given a date and a set of coordinates, you can compute where the sun is: its declination, and the equation of time, which is the difference between apparent solar time and mean solar time and which swings by about sixteen minutes over the year. From those two numbers plus latitude you get solar noon, and from solar noon you get everything else by solving for the hour angle at which the sun sits at a given altitude.

Dhuhr is solar noon plus a small offset. Sunrise and maghrib are the sun at the horizon, with a correction for atmospheric refraction and for the fact that the sun is a disc rather than a point, which together are worth about fifty minutes of arc. Fajr and isha are the sun at a chosen angle below the horizon, and the choice of angle is where it stops being astronomy and starts being jurisprudence.

There is no single correct angle. Different bodies use different ones. Eighteen degrees for fajr and seventeen for isha in one convention; nineteen and seventeen in another; fifteen and fifteen in another. These are not approximations of a true value that someone could measure better. They are different definitions, held by different communities, and an app that quietly picks one and presents it as the answer is making a decision it has no business making silently.

Asr is its own case, and the one I got wrong first. It is defined by shadow length rather than by solar angle: the moment when an object's shadow equals its own length plus its noon shadow, or twice its own length plus its noon shadow, depending on the school. The Hanafi position gives a materially later time than the majority position, sometimes by the better part of an hour. That is not a rounding error to the person using the app. It is the difference between praying on time and not.

So the app asks. Once, during setup, in plain language, with a short explanation of what is being chosen and the ability to change it later. I resisted this for a while because a settings question in onboarding is a step where people drop out. It is also the only honest thing to do, and the drop-off turned out to be smaller than my anxiety about it.

The edge case that took longest was high latitude. Above roughly forty-eight degrees, there are nights in summer when the sun never drops eighteen degrees below the horizon. Astronomical twilight simply does not end. The equation has no solution, and what you are supposed to show is not a mathematical question at all.

There are several conventions for this. One takes the last night on which the times were computable and holds them. One divides the interval between sunset and sunrise into fixed fractions. One uses the nearest latitude at which a solution exists. Communities in those places have settled on different answers, and again the app's job is not to decide, it is to ask and then be consistent.

Getting this wrong is quiet, which is what makes it dangerous. The app does not crash. It shows a time. The time is simply not the time that person's community prays, and unless they already know the answer they will not find out from the app that anything happened at all. Every failure mode in this product is a wrong number displayed confidently, which is worse than an error message and much harder to notice.

That shaped how I tested it. Unit tests against published tables from several authorities, across a spread of latitudes, with particular attention to solstices and to the days either side of the point where high-latitude solutions disappear. Not because the arithmetic is hard, but because the arithmetic is the part I could verify, and I wanted every remaining bug to be somewhere I had actually looked.

There is a battery dimension I did not anticipate. Computing five times for one day is nothing. Computing them repeatedly, as a widget refreshes and a lock screen wakes and a notification schedule is rebuilt, adds up in a way that shows in a battery report and gets you uninstalled. The times for a given day and location are fixed, so they are computed once and cached with the day and the rounded coordinates as the key. Obvious in hindsight. Not obvious at two in the morning when the first battery complaint arrived.

Timezones are their own small hell and I will not pretend to have solved them elegantly. The computation is in UTC and the display is in the device's current zone, which is correct until somebody crosses a boundary mid-day and the app has to decide whether today's remaining prayers belong to the old zone or the new one. The answer is the new one, because the person is physically there, but arriving at that answer took an embarrassing amount of staring.

Before any of that there was a question I had to settle for myself, which is whether a person should trust an app for this at all. The times have been calculated by hand and published by mosques for a very long time, and a phone is a new and slightly ridiculous thing to put in that chain. The answer I arrived at is that the app should agree with the local mosque, and where it cannot, it should be legible enough that somebody can see why. That is why the method is configurable and why the settings screen names the authorities rather than describing them as options one through four.

That decision cascades further than it looks. It means the app cannot round aggressively, because a minute of difference is the kind of thing somebody notices when comparing against a printed timetable. It means the app cannot silently improve a convention it thinks is wrong. And it means the app has to keep working when the person changes their mind, which sounds trivial until you have a week of notifications scheduled under the old method and have to decide what happens to them.

The astronomy itself is old and settled, which is a relief. The algorithms I used are the standard low-precision solar position formulae, accurate to well under a minute for this purpose, and they are a few dozen lines. The temptation is to reach for a higher precision model because it feels more rigorous. It is not more rigorous here, it is just slower, because the error that matters is not in the solar position at all. It is in the convention you chose and the coordinates you were given.

Understanding that changed where I spent my attention. I stopped worrying about the third decimal place of declination and started worrying about whether the app had the right coordinates at all, which is a much more likely source of a wrong answer and a much less interesting problem to work on.

Getting the coordinates wrong happens in ways that are quite mundane. The person denies location permission and the app falls back to a city they picked once, six months ago, in a different country. The device returns a cached fix from an airport. A VPN convinces some other part of the stack that the person is in Amsterdam. None of these are exotic and all of them produce times that are confidently wrong, which is the failure mode I keep coming back to.

So the app shows the location it is using, in words, on the main screen. Not coordinates. The place name. It is a small line of grey text under the next prayer, and it is there so that somebody looking at a time that seems wrong has one glance available that explains it. That line has probably prevented more support email than any other single thing in the product.

There is a related decision about what to do when the app has no location at all, on first launch, before permission. The easy answer is to block until it has one. The better answer turned out to be asking the person to pick a city, because a city is a thing they know, and it gives the app something to be correct about immediately while the permission conversation happens separately. First launch is the worst possible moment to be showing an empty state.

The bit of the codebase I am least happy with is the scheduling layer. It has to reconcile a set of computed times, a set of user preferences about which prayers get notifications, the platform's limit on how many notifications can be pending, and the fact that both major platforms behave differently when the app has not been opened in a while. It works, it is well tested, and it is the only part I would not enjoy explaining to somebody.

The pending-notification limit deserves a mention because it is a hard constraint that shapes the design. There is a cap on how many you can have queued. Five prayers a day against that cap gives you a fixed horizon, and if the person does not open the app before the horizon runs out, the notifications simply stop with no error anywhere. The mitigation is a background refresh that the operating system may or may not grant you, and a horizon short enough that ordinary use keeps refilling it. This is a real and permanent piece of fragility in every app of this kind and I have not found a design that removes it.

Battery came up again here. Rebuilding a week of schedules is cheap in isolation and expensive if you do it on every foreground event, which is what the first version did because it was the simplest thing that was obviously correct. The fix was a stored fingerprint of the inputs, so a rebuild only happens when the coordinates, the date or the settings have actually changed. Obvious, and it took a battery complaint to make me look.

A last note on the shape of the code, because it is the part that made all of the above tolerable. The astronomy is a pure function: date, coordinates and method in, five times out. No storage, no clock, no platform. Everything stateful sits outside it. That separation was not foresight, it fell out of wanting the tests to be simple, and it is the reason the scheduling layer could be rewritten twice without anybody worrying about whether the times were still right.

It also means the interesting part of this product is about two hundred lines and could be lifted out and used anywhere. The other several thousand lines are the app around it: permissions, notifications, widgets, settings, onboarding, the things that decide whether anyone gets as far as the two hundred lines that matter. That ratio is not a complaint. It is what building a product is, and I would rather know it going in.

I read the app's own analytics for exactly one thing, which is whether people are still opening it in month three. Not sessions, not screens, not time in app. Retention at three months, because that is the only number that says whether the product did what it claimed. Everything else I could measure would tell me how the app is being used, and I already know how it is used: it sits there and is right five times a day.

Location is the other input, and it is a smaller problem that is easy to get wrong in an expensive way. Coordinates only need to be accurate to a few kilometres for the times to be right to the minute, which means the app can ask for coarse location rather than precise, and the permission prompt is correspondingly easier to say yes to. It also means the last known position is almost always good enough, so the app does not need a fix before it can answer.

I cache the coordinates rounded to two decimal places, which is roughly a kilometre. That rounding is doing two jobs. It makes the cache key stable, so walking around town does not invalidate the day, and it means the value sitting in storage is not a precise record of where somebody lives. Neither of those was the original motivation, which was just to stop recomputing, and both turned out to matter more than the performance.

Notifications are where the offline design pays for itself in a way I did not anticipate. A notification has to be scheduled in advance, because the operating system will not wake the app at the right moment to decide. So the app schedules a week ahead. If the times came from a network call, then a week of notifications would be a week of stale answers waiting to fire, and the failure would land as a wrong call to prayer rather than as an error. Computing locally means the schedule can be rebuilt at any point, from nothing, in a few milliseconds.

That rebuild happens more often than you would think: on location change, on settings change, at midnight, on app open, and after any system event that might have cleared the queue. Each rebuild is cheap only because there is no network in it. The version that called an API could not have done this, and I would have ended up with a more complicated design built around not being able to.

Daylight saving is the thing everyone warns you about, and in this specific case it is less dangerous than it looks, because the computation is in UTC and the conversion happens at display time. What does bite is scheduling across a transition. A notification scheduled for a wall clock time on the far side of a change can land an hour out if you are not careful about which representation you hand to the operating system. The fix is to schedule in absolute time and let the platform do the conversion, which is obvious once you have got it wrong once.

Testing this was mostly a question of deciding what could actually be verified. The astronomy has published reference tables from several authorities, so that part is straightforward: pick a spread of cities, a spread of dates weighted toward the solstices, and assert to the minute. Those tests are boring and they have caught real regressions twice, both times after refactors I was confident about.

The harder tests are the ones about the seam between the astronomy and the product. Does the notification schedule survive a timezone change mid-day. Does the widget show tomorrow rather than a blank when the last prayer has passed. Does the app do something sensible on the day a high-latitude solution disappears rather than showing an empty string. None of those are astronomy bugs and all of them are the kind of thing that gets a one-star review that says only that the app is broken.

I also keep a small harness that walks a full year, day by day, at a dozen latitudes from the equator to well inside the arctic circle, and asserts only that the times are monotonic and that none of them is missing. It does not check correctness, because at that point I do not have a reference. It checks that nothing is absurd, which is a lower bar and has caught more than the precise tests did.

One thing I decided not to do, and still think about: the app does not show seconds, and it does not show the underlying angles or the calculation method on the main screen. The information is in settings for anyone who wants it. Putting it on the main screen would have made the app look more serious and would have appealed to precisely the sort of person who is already using something else. The person I was building for wants to know whether it is time, not how the number was arrived at.

The qibla direction is in there as well, and it is a much smaller problem than the times: a great circle bearing from the current coordinates to a fixed point. What is not small is the compass, which on a phone is noisy, affected by nearby metal, and requires a calibration gesture that people do not know about. The arrow is smoothed heavily and the app says plainly when the reading is unreliable rather than pointing confidently in a direction it is not sure about. Same principle as the times: a wrong number shown confidently is the failure mode to design against.

What all of this buys is one property: there is no network call in the critical path. Not a fast one, not a cached one. None. The app does not sync anything in order to be correct. Everything it needs is the date, the coordinates and the settings, all of which are on the device.

That was never a privacy pitch, though it is a nice side effect that the app has nothing to send anywhere. It was never a performance pitch either, though it is instant. It was the only way I could see to make a promise I was actually able to keep. If the promise is that this thing is right when you need it, and the times come from somewhere else, then the promise is not mine to make.

If I were starting again I would still build the API version first, and I want to be clear about that because it sounds like a contradiction. Shipping the API version took a weekend and told me whether anyone wanted the product at all. Three weeks of astronomy for a product nobody uses is three weeks gone. The order matters: prove the thing is wanted with the cheap version, then pay for the property that makes the promise real, before the user base is large enough that being wrong is expensive.

What I would not do again is leave the statistics screen in. It was the first thing I built, back when I was still thinking of this as a tracker, and it quietly pulls the whole product back toward the category I was trying to leave. Every time somebody opens it they are being invited to look at a record of failure. That screen is coming out, and the fact that it has survived two redesigns is a good illustration of how hard it is to delete something you built first.

Three weeks for a property most people will never consciously notice. That is a hard trade to justify on a spreadsheet, and I would make it again. The whole product is a claim about reliability at a specific moment, and you cannot make that claim on top of a dependency you do not control.