Skip to main content
← Back to blog

How we built Zmanym

Zmanym computes Shabbat and zmanim times in process from NOAA solar equations, with no third-party calls on the hot path. Here is the architecture and the mistakes that shaped it.

by

engineeringarchitecturezmanim

Zmanym (listed in the App Store as Zmanim) gives candle-lighting, Havdalah and daily zmanim times for any location, on the web and in an iOS app. Every time is computed in process from NOAA solar equations by a small engine we vendor into the app, so a lookup needs no third-party service. This post walks through the architecture, the pipeline from a place name to a rendered time, and three decisions that cost us something.

The problem

Zmanim are wall-clock times at a place: sunrise, sunset, the latest time for the morning Shema, nightfall, and the candle-lighting time before Shabbat. Candle lighting is the one people act on, so an error there is not a cosmetic bug. A wrong time can mean someone lights late.

We started by calling a public calendar API. That worked until we counted what we depended on: a location search service, a geocoder and a zmanim endpoint, all on the hot path of every page load. On 2026-09-08 we removed the last of those calls. Nothing in the app now talks to a third-party zmanim or location service.

Architecture

Zmanym architectureThe iOS app and the website call public API routes. Routes check a cache, then a dispatch layer calls an in-process engine that reads location tables. Postgres holds cities and subscribers and feeds a weekly reminder job.iOS app + widgetsWebsitePublic API routesrate limitedRedis cacheDispatchEngineNOAA + placesPostgresWeekly reminders
Requests hit a cache first, then a dispatch layer, then an engine that runs in the same process.

The web app is Next.js with the API under src/app/api/hebcal/: shabbat, zmanim, zmanim/days, search and coords. The route names keep the vocabulary of the public calendar API we started from, because stored data and every component already speak it. The iOS app is SwiftUI, targets iOS 18 and ships with widgets, an Apple Watch app and a Hebrew right-to-left interface. It calls the same public routes as the website.

From a place to a time

A request names a place by GeoNames id, never by raw coordinates. src/lib/zmanim/location-directory.ts maps that id to latitude, longitude and time zone. It lives in Redis when configured and in an in-memory map otherwise, with no expiry, because the coordinates of a town do not go stale.

The location data comes from the engine's own tables: gzipped GeoNames files, committed to the repository and also traced into the deployed function through outputFileTracingIncludes in next.config.mjs. Both are required, and neither alone is enough. The city table is about 1.3 MB and loads eagerly. The postal table is about 17 MB compressed and 86 MB parsed, so the engine loads it lazily and only a postal-code search pays for it. City results carry the real GeoNames id. Postal results use a synthetic id such as postal:US:33139:0 that is deliberately not numeric, so nothing persists a postal record by accident.

src/lib/zmanim/dispatch.ts then picks an engine from ZMANIM_ENGINE. The default, sdk, calls src/lib/zmanim/zmanym-sdk.ts, which builds a nested tree of about 89 zmanim per day and hands it to zmanym-shape.ts. That file flattens the tree into the camelCase shape every consumer reads. A zman that never occurs, such as dawn at 16.1 degrees in Reykjavik in June, is omitted instead of emitted as null or NaN.

Behind the scenes, Postgres through Drizzle holds the cities table, seeded with 55 Florida cities, and the subscribers table. A weekly reminder route, verified by a signed request, renders and sends the emails, and accepts a dry-run flag that renders everything without sending.

Trade-off 1: compute in process, not over HTTP

The dispatch layer once chose among four engines: @hebcal/core running locally, our own computation behind a separate HTTP service, a live client for the public calendar API, and the in-process one. Today the same vendored engine, @cuanto-labs/zmanym-engine, powers both the in-process path and the HTTP service. It has zero runtime dependencies, and it implements the NOAA equations directly rather than deriving from another library's source.

Moving it in process removed a network hop and a deployment we had to keep alive. It also removed @hebcal/core, which is GPL-2.0, from the hot path. The cost is a vendoring step: scripts/vendor-zmanym-engine.sh regenerates the folder, and nobody edits it by hand. To prove the two paths agree, tests/unit/zmanym-sdk-equivalence.test.ts checks the in-process numbers against real responses captured from the HTTP service.

Trade-off 2: a time zone bug that looked fine

Our first in-process version found time zones with geo-tz, which reads about 70 MB of tile files from disk when it is called. Next's file tracing never bundled them into the deployed function, so every lookup threw, and a catch block quietly returned UTC. The instants were correct, but every zman rendered with a +00:00 offset. Miami candle lighting showed as 11:10pm instead of 7:10pm, and no log recorded a problem.

On 2026-09-08 we switched to tz-lookup, a single module of roughly 170 KB with its table compiled in. We changed the fallback to America/New_York, because a failure should degrade toward where most users are, not toward a zone nobody lives in. We also made every location record carry its own IANA time zone, so the render path never has to infer one.

Trade-off 3: the cache that outlived Shabbat

Daily zmanim cache for 24 hours under zmanim:{location}:{date}. Shabbat times were first keyed by ISO week with a week-long expiry. Then Erev Yom Kippur 2026 served the previous Friday's candle lighting all day, because the week-keyed entry had been cut on Sunday.

src/lib/zmanim/cache.ts now keys Shabbat and yom tov by the hour with a two-hour expiry, so a block that begins midweek appears within the hour. A CACHE_VERSION constant, currently v3, is bumped whenever a change alters returned values for an unchanged request.

Candle-lighting minutes carry the same kind of risk. The engine takes a plain number and picks none from a location table, by design. resolveCandleMinutes() in the SDK module supplies 18 by default, 40 for Jerusalem and 30 for Haifa and Zikhron Ya'akov. Getting that wrong is a silent 22-minute error on the number people act on.

How we check the numbers

tests/unit/zmanim-oracle.test.ts compares every computed zman to captured Hebcal responses for 10 cities across 60 days, plus 10 Shabbat windows. The cities span both hemispheres, Israel's own conventions, non-US daylight saving rules and 61 degrees north. It runs with the normal test command, with no flag and no network. When it fails, we assume our engine is wrong and treat any exception as pinned and explained, never as a looser tolerance.

What is next

The repository lists two open items. An Android port is specified in android/PORT-SPEC.md, in Kotlin and Jetpack Compose, against the same public routes. The weekly reminder job is built, and its scheduled trigger is tracked as an open setup item in docs/SETUP-GAPS.md. Facts in this post come from the repository as of October 2026.

For the people and the studio behind the product, see the about page.

Frequently asked questions

How does Zmanym calculate zmanim?
It applies the published NOAA solar equations inside a dependency-free engine that runs in the same process as the web app, then derives about 89 zmanim per day for a location and date. No request waits on another company's service.
Where do Zmanym's locations and time zones come from?
From GeoNames tables shipped inside the engine. Every city record carries its own IANA time zone, and postal codes resolve to a city through a second table that is loaded only when someone searches by postal code.
How do you know the times are accurate?
A test runs on every build and compares each computed time against captured Hebcal responses for 10 cities across 60 days, plus 10 Shabbat windows. A mismatch fails the build.
Why does candle lighting differ for Jerusalem?
The engine takes candle-lighting minutes as a plain number. The app supplies 18 minutes before sunset by default, 40 for Jerusalem and 30 for Haifa and Zikhron Ya'akov, which are the conventions in common use.
Is there an Android app?
Not yet. An Android port is specified in the repository, in Kotlin and Jetpack Compose, but the iOS app is the shipped native client today.