ASU Class Watcher Watch a class

Low-Latency Seat Availability Monitoring over a Public Course Catalog

Abstract—Seats in full university classes open unpredictably and are taken within minutes. We describe ASU Class Watcher, a serverless system that polls a public course-catalog API on a fixed 15-second cadence and pushes an alert to a student's phone when a watched class gains a seat they are eligible for. We present a seat model that separates seats reserved for specific groups from seats open to anyone, a polling schedule that sustains sub-minute sweeps on infrastructure that only supports minute-level triggers, and a deduplication scheme built on an atomic compare-and-set that delivers at most one alert per opening under concurrent sweeps. The system requires no accounts, stores no personal identifiers, and counts visitors with daily-rotated hashes in a HyperLogLog. In production the median sweep takes 0.40 s, giving an expected delay of about 8 s from the moment the catalog reflects an opening to the moment the alert is handed to the push service; delivery to the phone is not measured. We also report a tail-latency failure in an earlier version, caused by unbounded upstream requests, and the fix that bounds it.

Index Terms—polling systems, serverless computing, idempotent notification, cardinality estimation, privacy-preserving analytics.


1Introduction

During registration, a full class is not finished: students drop, sections are expanded, and reservations expire. Each change frees a seat for a short window before someone else takes it. The usual strategy, refreshing the class-search page by hand, is slow and tiring, and it misses openings that happen overnight.

ASU Class Watcher replaces the refresh loop with a server-side one. A student enters a five-digit class number, chooses when they want to hear about it, and subscribes their phone once to a private push topic. The service then checks the class every 15 seconds and alerts the student when their rule is met. The student still registers themselves; the system never touches an ASU login.

The design goals were: (i) a short delay between a seat opening and the alert, (ii) never alerting twice for the same opening, (iii) no accounts or personal data, and (iv) graceful behavior when the upstream catalog is slow or unavailable.

2System Architecture

The system has four parts (Fig. 1). The client is a single static HTML page with no framework or build step. Server logic runs as stateless Node.js functions on Vercel. State lives in Upstash Redis, a hosted Redis reachable over HTTPS. Alerts are delivered through ntfy, an open-source publish/subscribe push service[4].

Table 1 lists the public endpoints. Every endpoint validates its input strictly (class numbers must match ^\d{5}$; topics must match the generated format), and every public endpoint is rate-limited per network.

Table 1. Public endpoints.
EndpointMethodPurpose
/api/classGETLive seat counts for one class; edge-cached for 30 s
/api/subscribePOSTAdd up to 5 watches; mint a topic on first use
GETList a topic's watches with live counts
DELETERemove one watch
/api/checkGETRun sweeps; requires a secret key
/api/hitPOSTAnonymous visit counter
/api/feedbackPOSTStore an anonymous feature request
Fig. 1. System architecture. Solid edges are synchronous requests; the dotted edge is a fire-and-forget beacon. The external pinger is the only clock in the system.

3Seat Model

For each class the catalog returns a capacity, an enrollment count, and a list of reservation groups, each with its own capacity and enrollment. We found that the class record's enrollment total can trail the live figure: for class 18607, a 220-seat section, the record reported 195 enrolled while the live count reported 215 in the same response (observed October 29, 2026). The system therefore reads the live counts. Let C be capacity and E live enrollment. Then

O = max(0, C − E)(1)

A reservation group g holds Cg seats, of which Eg are filled. Seats a group has not filled cannot go to anyone outside it, so the open seats held back are

R = min(O, Σg max(0, Cg − Eg))(2)

and the seats open to anyone are

G = O − R(3)

Each watch carries a threshold k ∈ {1, 2, 3, 5, 10} and a flag selecting whether reserved seats count. The watch is satisfied when

s ≥ k, where s = G if reserved seats are excluded, else O(4)

Excluding reserved seats is the default, because a seat the student cannot take is not actionable.

4Polling Schedule

Vercel's free tier only schedules jobs daily, so an external pinger calls /api/check once a minute. A naive design would sweep once per call, giving a worst-case delay near 60 s. Instead, the function responds 202 immediately and continues in the background through waitUntil[5], repeating a sweep every Δ = 15 s until about 55 s have elapsed (Fig. 2). Sweeps therefore begin at t ≈ 0, 15, 30, 45 s, and the next ping resumes the cadence at 60 s. The pinger's own timing jitters by a few seconds, so the gap across a minute boundary varies around 15 s; Section 4.3 reports the observed gaps. Responding first also means a pinger with a short timeout cannot cut a sweep short.

A sweep reads the watched set, then looks up classes in parallel batches of 10. We observed the catalog occasionally leave a single request hanging while an immediate repeat answered in about 0.3 s, so each lookup has a 6 s timeout and one retry; a class that still fails is simply retried on the next sweep. A class is fetched once per sweep however many students watch it.

4.1 Detection delay

Let an opening appear in the catalog at a time uniformly distributed between sweeps. This is idealized: openings cluster around registration windows and drop deadlines, so what follows is an average over time, not a promise for any single opening. The delay from the catalog reflecting an opening to the alert being handed to the push service is

L = w + d,  w ~ U[0, Δ)(5)

where d is the sweep duration up to that class. With Δ = 15 s, E[w] = 7.5 s, and with the median sweep in Table 2, E[L] ≈ 8 s. This bound holds only while sweeps stay well under Δ; Section 4.3 shows what happened when they did not. Two delays are outside L: push delivery from ntfy to the phone, which we have not measured, and any lag between real registration and the catalog.

4.2 Upstream load

With N distinct watched classes, the system issues up to 4N catalog requests per minute (fewer when a slow sweep pushes the next one past the run's cutoff, more by at most N when a retry fires), independent of the number of students. The service caps N ≤ 300, bounding load at about 1,200 requests per minute in bursts of 10. Seat-map previews are served from a 30 s edge cache, so page traffic does not multiply catalog traffic.

4.3 Measured sweep latency

Each sweep logs its duration. Table 2 summarizes every sweep in the production logs available to us on October 29, 2026, split at the deploy that added lookup timeouts. Three to five classes were watched throughout.

Table 2. Sweep duration and gap between sweep starts, in seconds.
Versionnp50p95p99maxgap p95gap max
No lookup timeout1810.3722.640.454.522.645.5
6 s timeout + 1 retry250.400.691.301.3015.416.7

The first version had no timeout on catalog requests. Most sweeps were fast, but a request the catalog never answered held its whole sweep open, and since the next sweep waits for the current one, the cadence collapsed: the gap between sweeps reached 45.5 s. The fix gives each lookup a 6 s timeout and one retry, after we observed the catalog leaving roughly one request in thirty hanging while an immediate repeat answered in about 0.3 s. A lookup now takes at most about 12 s, so a sweep stays inside Δ even when the catalog misbehaves. After the fix, gaps between sweep starts stayed between 13.2 and 16.7 s; the spread around 15 s is the pinger's jitter at minute boundaries. The second row covers only 15:47–15:53 UTC, a much shorter window than the first (15:00–15:46 UTC), so its tail percentiles are not yet meaningful.

Fig. 2. One minute of operation. The function acknowledges the pinger before any work, then runs up to four sweeps inside waitUntil. Steps 6–8 run only for watches whose rule (4) is satisfied.

5Deduplicated Alert Delivery

Sweeps can overlap: a retried ping, a manual debug run, or a slow sweep running into the next. Without coordination, two sweeps can both observe an opening and both alert. Each (topic, class) pair therefore owns a flag ns:topic:class, and a sweep must win it with Redis SET … NX, which sets the key only if it is absent and does so atomically[2]. Exactly one concurrent sweep wins; the rest skip (Algorithm 1, Fig. 3).

Algorithm 1 Alert decision for one watch (topic τ, class c)
  1. if s ≥ k then ▷ rule (4)
  2. if SET ns:τ:c 1 NX ≠ OK then return ▷ lost the claim
  3. try push(τ, message)
  4. catch
  5. DEL ns:τ:c ▷ release; next sweep retries
  6. else
  7. DEL ns:τ:c ▷ class refilled; re-arm

The flag is set before the push is sent. If the push fails, the flag is released, so the next sweep retries. If the class fills again, the flag is cleared, so the next opening alerts again. The guarantee is therefore at most one alert per opening, with retry on reported failure. The one gap is a process crash between the claim and the push, which suppresses that alert until the class refills; we accept this rather than risk duplicates.

Fig. 3. Per-watch state. Claimed and Alerted both correspond to the flag being present in Redis.

6Identity Without Accounts

On a student's first watch, the server mints a topic seat-h, where h is 12 bytes from a cryptographic random generator, written in hex. The topic is the only link between a phone and its watches. With 96 bits of entropy, guessing a specific topic takes on the order of 295 attempts. In practice the guessing rate is also bounded by rate limits: our list endpoint limits requests per network, but guessing directly against ntfy is bounded only by ntfy's own limits, which we do not control. The topic is kept in the browser's local storage and reused even after every class is removed, so a phone already subscribed to it keeps receiving alerts. The cost of this design is that the topic is a bearer secret: anyone who has it can read the alerts.

7Robustness

We verified these behaviors with an end-to-end test suite (tests/api.test.mjs, run with npm test) that runs the production handlers against an in-memory Redis emulator and the live catalog. Among its 21 checks, two concurrent sweeps over one open class produce exactly one alert. The emulator shows that our code relies on the claim correctly; the atomicity of the claim itself comes from Redis's documented SET … NX semantics[2] and was not tested against a live Upstash instance under concurrency.

8Privacy-Preserving Visit Counts

Visits are counted without cookies. For each page view the server computes

v = SHA-256(ip ‖ ua ‖ day ‖ secret)(6)

and adds v to a per-day HyperLogLog[1], which estimates the number of distinct values without storing them. Redis's implementation has a standard error of 0.81%[3]. Because the day is an input to the hash, the same visitor yields unrelated values on different days and cannot be followed across them, and raw addresses are never written to storage. Known crawlers and link-preview agents are excluded.

9Limitations

The system can be no faster than the catalog it reads, which may trail real registration. The external pinger is a single point of failure: if it stops, sweeps stop. Push delivery depends on a third-party service, and the bearer-topic model means a leaked topic leaks that student's alerts. The fail-open limiter means a Redis outage briefly removes rate limiting. Finally, the evaluation is small: about an hour of production on one day with three to five watched classes. We have not measured behavior near the 300-class cap or end-to-end delivery to a phone.

References

  1. P. Flajolet, É. Fusy, O. Gandouet, and F. Meunier, "HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm," in Proc. Conf. Analysis of Algorithms (AofA), 2007.
  2. Redis, "SET," command reference. redis.io/docs/latest/commands/set
  3. Redis, "HyperLogLog," data type documentation. redis.io/docs
  4. ntfy, "ntfy documentation." docs.ntfy.sh
  5. Vercel, "Vercel Functions package: waitUntil." vercel.com/docs/functions