skip to content

back to seatsnipe

seatsnipe at fall add/drop

october 2026 · how a seat watcher for gsu reached 100 students in its first 24 hours and 152 by the end of fall add/drop, and what broke on the way.

context

at gsu the classes everyone needs fill up during registration, and a seat only opens when someone drops. seatsnipe watches a full section and texts you the second a seat opens. it runs inside nudge, the imessage assistant i'd already built, so you sign up by texting it the class. it launched on 24 august 2026, the start of fall add/drop.

constraints

banner, gsu's registration system, has no public api docs. a watch has to notice an opening within a minute or so when a class is in demand, without hammering gsu's servers. nobody can be texted twice for the same opening, or texted about a seat that was already open when they signed up. and nudge runs on serverless functions that die after each request, so the polling has to live in a separate always-on worker.

the first wall

the obvious move is to call banner's class search directly. it answers 200 with an empty result set, which is also what a real search returns when nothing matches. nothing errors. banner only returns data after a specific sequence, which i got by recording the real request chain in browser dev tools and replaying it until the order was right:

worker                         banner
  |  GET  registration           |
  |----------------------------->|  session cookie
  |  POST term/search            |
  |----------------------------->|  term bound to the session
  |  GET  classSearch            |
  |----------------------------->|  skip it and results stay empty
  |                              |
  |  then, for every search:     |
  |  POST resetDataForm          |
  |  GET  searchResults          |
  |----------------------------->|
  |<-----------------------------|  200, sections as json
  |                              |
  |  a step missing:             |
  |<-----------------------------|  200, empty results
  |  a stale session:            |
  |<-----------------------------|  302 to login, then 200 html

a few more things only live testing turned up. searchResults has to be a GET: a POST is accepted and silently ignores the filters. the course reference number filter is broken on gsu's instance and returns every section, so the worker searches by subject and course number and finds the section itself. and the term list has to come from banner's own getTerms call, not be worked out from the month.

the decision

the session is shared state: cookies, the selected term, the reset form. two polls interleaving their steps would thrash it, so every banner request goes through a promise-chain mutex with a 500ms rate limiter. polling is tiered, 60 to 90 seconds in a surge, 5 minutes normally, 15 overnight, and the tier is a flag in redis, so it changes without a deploy. five banner errors in a row trip a circuit breaker that pauses for 10 minutes.

nobody gets the same alert twice. a watch fires only on a change in the seat count, it starts from the count at the moment you sign up, and alerts are held to a 10-minute cooldown and 6 a day.

what broke

the week before launch, in one pass of fixes. concurrent polls thrashed the shared session and tripped banner's rate limits, which is where the mutex came from. a stale session made banner redirect to its login page, fetch followed the redirect, and the login page came back as a 200, so every poll after that failed silently on a dead session. now any response that isn't json gets one re-login and a retry. a new watch on a section that already had seats alerted immediately, so watches are seeded with the current count. the watcher wrote a seat event on every poll whether anything changed or not, about 288 useless rows a day per watch, and now writes only on a change. webhook dedup lived in memory, which does nothing when vercel runs several copies of the function at once, so it moved to redis set nx. and if the model or the database went down mid-registration, people would be left hanging, so an outage now texts the affected users when it recovers, and reminders fall back to plain text so they still go out.

launch week. students typed subjects the way they say them, eco and eng, and banner calls them econ and engl. those watches found nothing and said nothing. a subject normalizer fixed it, with a second pass the next day when eng turned up. two days in i added a dead-man switch to the watcher, so a watcher that stops ticking gets noticed instead of silently missing seats. and a new user's first text got a generic welcome instead of an answer; now the first message goes through the agent and the welcome fits what they asked.

numbers

100 students in the first 24 hours, 152 by 3 september. there is no seats-caught figure yet, so this page doesn't give one. watches expire when add/drop closes, so seatsnipe is quiet until spring registration.

what i'd change

check the subject against banner's own subject list when the watch is created, instead of normalizing after a student notices nothing happened. and ship the dead-man switch before launch, not two days after.

back to seatsnipe