<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Abdul-Salam Zakaria</title>
    <description>The latest articles on DEV Community by Abdul-Salam Zakaria (@abdulsalamzak).</description>
    <link>https://dev.to/abdulsalamzak</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810757%2Fefb1d8b2-368c-4d1c-8465-d7e1d01676a1.png</url>
      <title>DEV Community: Abdul-Salam Zakaria</title>
      <link>https://dev.to/abdulsalamzak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abdulsalamzak"/>
    <language>en</language>
    <item>
      <title>Building Push Notifications in GusLift</title>
      <dc:creator>Abdul-Salam Zakaria</dc:creator>
      <pubDate>Tue, 12 May 2026 17:37:00 +0000</pubDate>
      <link>https://dev.to/guslift/building-push-notifications-in-guslift-iec</link>
      <guid>https://dev.to/guslift/building-push-notifications-in-guslift-iec</guid>
      <description>&lt;p&gt;GusLift connects student drivers with riders heading the same way. The matching itself happens over a WebSocket, which is fine when both people are staring at the app. The problem is that most of the time they aren't. Someone requests a ride, locks their phone, and the driver on the other side of campus has no idea anyone is waiting.&lt;/p&gt;

&lt;p&gt;This post walks through how push notifications were wired into GusLift across the three services that make up the product: the Expo mobile app, the Next.js backend on Cloudflare, and the matching worker (a Durable Object on Cloudflare Workers).&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;There are exactly two moments where a push notification meaningfully changes user behavior:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A driver picks a rider from the waiting list. The rider needs to know to open the app and accept.&lt;/li&gt;
&lt;li&gt;The rider accepts. The driver needs to know the ride is locked in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else, like seat counts updating or another driver joining the slot, is noise. Sending push for those would train users to ignore the notifications they actually need. So the scope was deliberately narrow: two event types, both tied to a confirmed action on the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  mobile (Expo)                backend (Next.js)              matching-worker (DO)
  -------------                ----------------               --------------------
  registerCurrentUser     --&amp;gt;  POST /api/notifications/token
  PushToken()                  upsert into PushTokens
                                                              MatchingRoom event:
                                                                rider reserved
                                                                  or match accepted
                                                              --&amp;gt;  sendMatchPush
                                                                   Notification()
                                                                   reads PushTokens
                                                                   POST exp.host
  Notifications handler   &amp;lt;--  (Expo push service)            &amp;lt;--
  routes user to screen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mobile app owns token acquisition. The backend owns token storage. The matching worker owns dispatch. None of them know about the other two beyond a shared Supabase table and a stable contract on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storage layer
&lt;/h2&gt;

&lt;p&gt;The whole feature hinges on one table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"PushTokens"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"User"&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;without&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;without&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;token&lt;/code&gt; is unique, not &lt;code&gt;(user_id, token)&lt;/code&gt;. The same physical device could in theory be reused across user accounts (think shared family phone, or a dev account on a personal device), and we want the latest user to claim it. Upserts use &lt;code&gt;onConflict: "token"&lt;/code&gt; so the row gets reassigned cleanly instead of duplicating.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is_active&lt;/code&gt; is a boolean rather than deleting rows. When Expo reports &lt;code&gt;DeviceNotRegistered&lt;/code&gt; we mark the row inactive. This keeps history useful for debugging "why didn't I get a push" complaints without leaking junk into the active set.&lt;/li&gt;
&lt;li&gt;A small &lt;code&gt;set_push_tokens_updated_at&lt;/code&gt; trigger keeps &lt;code&gt;updated_at&lt;/code&gt; honest on every write. Push tokens rotate, especially on iOS, so the freshness of a token row is something we look at when triaging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Indexes are on &lt;code&gt;user_id&lt;/code&gt; and on &lt;code&gt;(user_id, is_active)&lt;/code&gt;. The hot read path is always "give me the active tokens for this one user," which the composite index covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering a token from the mobile app
&lt;/h2&gt;

&lt;p&gt;The mobile side lives in &lt;code&gt;mobile/lib/pushNotifications.js&lt;/code&gt;. It runs on app start and also whenever the app comes back to the foreground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerCurrentUserPushToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerCurrentUserPushToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "on resume" call matters more than it looks. Tokens can be revoked or rotated while the app is backgrounded (an OS update, a permission change, a reinstall), and Expo will hand back a different value next time you ask. Re-registering on foreground is the cheapest way to stay correct.&lt;/p&gt;

&lt;p&gt;The registration function itself is deliberately defensive. It walks through these steps and bails out loudly at any point that fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Skip on web entirely. There is no Expo push token there.&lt;/li&gt;
&lt;li&gt;Look up the user in &lt;code&gt;AsyncStorage&lt;/code&gt;. No stored &lt;code&gt;@user&lt;/code&gt;, no registration, since we'd have nowhere to attribute the token.&lt;/li&gt;
&lt;li&gt;On Android, create the &lt;code&gt;default&lt;/code&gt; notification channel with &lt;code&gt;MAX&lt;/code&gt; importance. Without this, notifications arrive but never make a sound or appear as a heads-up.&lt;/li&gt;
&lt;li&gt;Ask for permissions, requesting them if not already granted.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;getDevicePushTokenAsync&lt;/code&gt; for logging, then &lt;code&gt;getExpoPushTokenAsync&lt;/code&gt; with the EAS &lt;code&gt;projectId&lt;/code&gt; from &lt;code&gt;Constants.expoConfig.extra.eas&lt;/code&gt;. The Expo token is what the backend actually stores.&lt;/li&gt;
&lt;li&gt;POST it to &lt;code&gt;${BACKEND_URL}/api/notifications/token&lt;/code&gt; with an &lt;code&gt;x-user-id&lt;/code&gt; header.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step logs a &lt;code&gt;[push] ...&lt;/code&gt; line. That has paid for itself many times over. When a user reports "I never got the notification," the first question is always "what does logcat say at app start," and the answer comes from these breadcrumbs.&lt;/p&gt;

&lt;p&gt;Sign-out goes through &lt;code&gt;deactivateCurrentUserPushToken&lt;/code&gt;, which calls &lt;code&gt;DELETE&lt;/code&gt; on the same endpoint. The server marks the row inactive rather than deleting it. If the device immediately signs back in we re-activate by upsert, no row churn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend endpoint
&lt;/h2&gt;

&lt;p&gt;The token route is one file: &lt;code&gt;backend/app/api/notifications/token/route.ts&lt;/code&gt;. It exposes &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The auth story is intentionally simple. The handler accepts a user id in three ways, in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fromHeader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bearer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseAuthHeaderUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;bearer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bodyUserId&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// fall back to Supabase auth.getUser(bearer)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-user-id&lt;/code&gt; is what the mobile app sends today because we already have a verified Google user id in &lt;code&gt;AsyncStorage&lt;/code&gt; after login. The &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; path is there for when we move the rest of the API behind Supabase JWTs. Keeping both paths in one helper means we can flip the flag on auth without touching the notifications surface.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;POST&lt;/code&gt; body looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ExponentPushToken[...]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ios"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler normalizes both fields (trim, lowercase platform), then upserts on &lt;code&gt;token&lt;/code&gt;. That's the entire write path. The service role key lives in &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt;, since this endpoint needs to write across users without going through RLS.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DELETE&lt;/code&gt; is almost the same shape. If the body includes a specific token, only that row gets deactivated. If it doesn't, every active token for that user gets flipped off. The "deactivate all" variant is what runs on sign-out, so a shared device doesn't keep buzzing the previous user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dispatching from the matching worker
&lt;/h2&gt;

&lt;p&gt;The matching worker is a Cloudflare Durable Object. Each ride slot (location + day + start_time) is its own room, and inside that room there's a small state machine driving who is waiting, who is reserved, and who is confirmed. The push dispatch is hooked into exactly two transitions in &lt;code&gt;MatchingRoom.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldSendPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driver_selected_rider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendMatchPushNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;recipientUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driver_selected_rider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;riderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;driverId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldSendPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rider_confirmed_match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendMatchPushNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;recipientUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rider_confirmed_match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;riderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;driverId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;rideId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ride&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the call is fire and forget (&lt;code&gt;void&lt;/code&gt;). The state machine should never wait on Expo, and a failed push should never break a match. Second, &lt;code&gt;shouldSendPush&lt;/code&gt; gates every call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why dedupe is necessary
&lt;/h3&gt;

&lt;p&gt;Durable Objects can replay events. State recovery, client reconnects, even a rider rapidly tapping "accept" can cause the same logical transition to fire twice. Without dedupe, the user gets two identical notifications back-to-back, which feels broken even though it isn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shouldSendPush&lt;/code&gt; keeps an in-memory map keyed by &lt;code&gt;${slotKey}:${riderId}:${driverId}:${eventType}:${bucket}&lt;/code&gt;, where &lt;code&gt;bucket = floor(now / 30s)&lt;/code&gt;. Anything inside the same 30-second window for the same pair and event type is dropped. The map self-prunes anything older than 2 minutes, so it doesn't grow unbounded.&lt;/p&gt;

&lt;p&gt;This lives in the DO instance memory rather than Supabase. Cross-instance dedupe isn't needed because each slot only has one DO, and that DO is the only sender for the slot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Talking to Expo
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pushNotifications.ts&lt;/code&gt; in the worker is the actual sender. It does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch active tokens for the recipient out of &lt;code&gt;PushTokens&lt;/code&gt;. If a user has multiple devices, all of them get the message.&lt;/li&gt;
&lt;li&gt;Build a message per token and POST the array to &lt;code&gt;https://exp.host/--/api/v2/push/send&lt;/code&gt;. The payload sets &lt;code&gt;sound: "default"&lt;/code&gt;, a short human title and body, and stuffs the event metadata into &lt;code&gt;data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Walk the response tickets. Any token that comes back with &lt;code&gt;DeviceNotRegistered&lt;/code&gt; gets bulk-updated to &lt;code&gt;is_active = false&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cleanup step is the part that's easy to forget. Without it, a user who reinstalls the app accumulates stale token rows, every push attempt eats a slot in the array sent to Expo, and you get rate-limited for ghosts. Treating &lt;code&gt;DeviceNotRegistered&lt;/code&gt; as a hint to deactivate keeps the active set healthy with zero ops work.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data&lt;/code&gt; payload is the contract with the mobile app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// "driver_selected_rider" | "rider_confirmed_match"&lt;/span&gt;
  &lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;riderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driverId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ride_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rideId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// present only on rider_confirmed_match&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deep-linking from a tapped notification
&lt;/h2&gt;

&lt;p&gt;A push that just makes a sound is half a feature. The reason the data payload includes &lt;code&gt;type&lt;/code&gt; is so the mobile app can route the user to the correct screen when they tap the notification, instead of dropping them on the home screen.&lt;/p&gt;

&lt;p&gt;That listener lives in &lt;code&gt;mobile/app/_layout.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNotificationResponseReceivedListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driver_selected_rider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/rider/ScheduledRidesRider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rider_confirmed_match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/driver/ScheduledRidesDriver&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;setNotificationHandler&lt;/code&gt; above it controls in-app behavior. We show the banner and the list entry, but don't play a sound or set a badge while the app is foregrounded. The reasoning is that if the user is already in the app, the WebSocket has already delivered the same information through the UI, and an extra sound on top of that is annoying.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we fixed push notifications
&lt;/h2&gt;

&lt;p&gt;Everything above describes the design as if it landed clean. It didn't. The first end-to-end test had token registration logs that looked perfect, no notifications arriving on the Android device, and a matching flow that broke after a successful match (the rides screen rendered empty). What follows is the actual debug trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five things that were broken
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Wrong EAS project ownership.&lt;/strong&gt; The Expo project ID baked into &lt;code&gt;app.json&lt;/code&gt; belonged to an account I no longer had access to. &lt;code&gt;npx eas credentials&lt;/code&gt; returned &lt;code&gt;Entity not authorized&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix: removed the old &lt;code&gt;extra.eas.projectId&lt;/code&gt;, ran &lt;code&gt;npx eas init&lt;/code&gt; to mint a fresh one under my account, rebuilt the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Missing FCM credentials on the new Expo project.&lt;/strong&gt; Expo can issue an &lt;code&gt;ExponentPushToken[...]&lt;/code&gt; without FCM credentials, so registration looked fine, but &lt;code&gt;https://expo.dev/notifications&lt;/code&gt; returned &lt;code&gt;InvalidCredentials: Unable to retrieve the FCM server key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix: in Firebase Console under Project Settings, Service accounts, Generate new private key (not &lt;code&gt;google-services.json&lt;/code&gt;, that's a different file). Uploaded it via &lt;code&gt;npx eas credentials&lt;/code&gt;, Android, Google Service Account, FCM V1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Stale tokens from the old project still in the DB.&lt;/strong&gt; After re-creating the project, the old &lt;code&gt;ExponentPushToken[...]&lt;/code&gt; rows in &lt;code&gt;PushTokens&lt;/code&gt; were still &lt;code&gt;is_active = true&lt;/code&gt;. Mixing them with the new token in a single Expo batch made the whole send return 400.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;delete from "PushTokens" where user_id = '...';&lt;/code&gt; then re-open the app to register a fresh token under the new project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Backend swallowed Expo's error reason.&lt;/strong&gt; &lt;code&gt;pushNotifications.ts&lt;/code&gt; only logged the status code, so 400s were opaque.&lt;/p&gt;

&lt;p&gt;Fix: added the response body and the token list to the error log so future push failures self-explain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Notification tap went to the wrong screen.&lt;/strong&gt; &lt;code&gt;_layout.js&lt;/code&gt; routed &lt;code&gt;driver_selected_rider&lt;/code&gt; to &lt;code&gt;ScheduledRidesRider&lt;/code&gt; (upcoming rides), not the accept/reject card.&lt;/p&gt;

&lt;p&gt;Fix: routed to &lt;code&gt;/rider/AvailableDrivers&lt;/code&gt; with &lt;code&gt;driverId&lt;/code&gt; from the push payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Tap stacked a duplicate AvailableDrivers screen.&lt;/strong&gt; When the rider was already on &lt;code&gt;AvailableDrivers&lt;/code&gt; (because the in-app &lt;code&gt;match_request&lt;/code&gt; had already routed them there with full driver details), tapping the notification called &lt;code&gt;router.push&lt;/code&gt; again, pushing a second copy on top, hydrated only with &lt;code&gt;driverId&lt;/code&gt;, so it showed "Unknown Driver".&lt;/p&gt;

&lt;p&gt;Fix: added a &lt;code&gt;pathnameRef&lt;/code&gt; guard, skip the navigation if already on the target screen. Same guard for the driver-side &lt;code&gt;rider_confirmed_match&lt;/code&gt; notif.&lt;/p&gt;

&lt;h3&gt;
  
  
  Side bugs found and fixed along the way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty upcoming-rides screen after accept.&lt;/strong&gt; Worker wrote &lt;code&gt;ride_date&lt;/code&gt; in UTC; backend queried in local time. Aligned the writer to use local-date components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rider showing 3x on driver's screen.&lt;/strong&gt; Rider's WS reconnects re-sent &lt;code&gt;rider_request&lt;/code&gt;, and &lt;code&gt;handleRiderRequest&lt;/code&gt; had no dedupe. Now ignored if the rider is already waiting or in a pending match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;INVALID_STATE_ERR&lt;/code&gt; from &lt;code&gt;MatchingContext.send&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;?.&lt;/code&gt; only guarded null, not &lt;code&gt;CONNECTING&lt;/code&gt;/&lt;code&gt;CLOSING&lt;/code&gt; &lt;code&gt;readyState&lt;/code&gt;. Now checks &lt;code&gt;readyState === 1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The actual checklist for end-to-end push
&lt;/h3&gt;

&lt;p&gt;Working backwards from the bug list, the requirements turned out to be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Own the Expo project (account access).&lt;/li&gt;
&lt;li&gt;Upload an FCM V1 service-account key to it (so Expo can deliver to Android).&lt;/li&gt;
&lt;li&gt;Have a fresh, valid token registered in your DB.&lt;/li&gt;
&lt;li&gt;Have the server actually call the send (it was, the matching flow does).&lt;/li&gt;
&lt;li&gt;Route the tap somewhere useful, without stacking duplicate screens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Items 1 and 2 are environmental and have nothing to do with the code. Items 3 through 5 are the ones the codebase has to keep honest forever. Most of the time when push "stops working" for a user later, the cause will be one of those three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, what it bought
&lt;/h2&gt;

&lt;p&gt;In code, this feature is small. One SQL file, one Next.js route, one mobile lib, one worker module, and a handful of call sites in the existing matching room. The whole thing is a few hundred lines.&lt;/p&gt;

&lt;p&gt;What it bought is the ability to stop telling users "keep the app open while you wait." That single sentence was the biggest source of friction in early testing, and it didn't go away until tokens, dispatch, and dedupe were all in place.&lt;/p&gt;

&lt;p&gt;A few things would be worth picking up later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server-side dedupe at the Supabase layer would let us survive a DO restart without re-sending. Today the 30-second bucket protects the common cases but isn't bulletproof across instance churn.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Authorization&lt;/code&gt; header path in the token route is wired up but not exercised. Moving the mobile client onto Supabase JWTs would let us drop &lt;code&gt;x-user-id&lt;/code&gt; and the trust assumption that goes with it.&lt;/li&gt;
&lt;li&gt;Right now the notification title and body strings are hardcoded in the worker. A small templating layer would make it cheaper to localize and to A/B test the copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those block shipping. The current setup has been quietly delivering both event types reliably, deactivating dead tokens on its own, and routing taps to the correct screen, which is exactly the bar I wanted before writing about it.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>nextjs</category>
      <category>serverless</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Week 6: How GusLift Matches Rides in Real Time</title>
      <dc:creator>Abdul-Salam Zakaria</dc:creator>
      <pubDate>Sun, 15 Mar 2026 13:41:03 +0000</pubDate>
      <link>https://dev.to/guslift/week-6-how-guslift-matches-rides-in-real-time-41pl</link>
      <guid>https://dev.to/guslift/week-6-how-guslift-matches-rides-in-real-time-41pl</guid>
      <description>&lt;p&gt;Every school morning, a few drivers leave their dorms for campus, and a bunch of riders need seats. The core problem: match them fast, in the right direction, before the window closes.&lt;/p&gt;

&lt;p&gt;Here's how the matching engine actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Matching Room Abstraction
&lt;/h2&gt;

&lt;p&gt;We don't run one global pool. We partition by location, day, and departure time. A room key looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Westie:mon:08:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string becomes the identity of a Cloudflare Durable Object(DO), a live, in-memory process that owns all real-time state for that slot. Everyone heading from Westie at 8am Monday shares one room. Leaving at 10:30? Different room entirely. This keeps each instance small and focused. The matching room doesn't know or care about any other departure window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Into the Right Room
&lt;/h2&gt;

&lt;p&gt;When a user opens the app, a Cloudflare Worker handles the request. It authenticates, resolves the user's schedule, generates the slot key, and forwards the WebSocket connection to the right DO. The Worker is stateless, purely a router. All the interesting state lives in the object it points to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the DO Tracks
&lt;/h2&gt;

&lt;p&gt;Four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;drivers&lt;/code&gt; — map of driver ID → seats remaining&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;riders_waiting&lt;/code&gt; — FIFO queue of riders requesting a ride&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pending_matches&lt;/code&gt; — riders a driver has selected but who haven't confirmed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;connections&lt;/code&gt; — live WebSocket handles, one per user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every state change broadcasts to all connected clients. The frontend is a mirror of what the DO holds, nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flow
&lt;/h2&gt;

&lt;p&gt;Driver goes online → sends &lt;code&gt;driver_online&lt;/code&gt; with seat count → registered in the room, broadcast to everyone. Rider wants a seat → sends &lt;code&gt;rider_request&lt;/code&gt; → added to the queue.&lt;/p&gt;

&lt;p&gt;Driver picks someone. That rider moves out of &lt;code&gt;riders_waiting&lt;/code&gt; into &lt;code&gt;pending_matches&lt;/code&gt; and gets a &lt;code&gt;match_request&lt;/code&gt; pushed to their socket. They have 30 seconds to accept. No response → back to the queue. They accept → seat decrements, ride written to Postgres, room updates for everyone.&lt;/p&gt;

&lt;p&gt;The part that could get hairy is concurrent events — two drivers selecting the same rider at the same time. The DO's single-threaded execution model handles that without any extra locking. One message at a time, in order. It's one of the legitimately nice properties of this architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Durable Objects
&lt;/h2&gt;

&lt;p&gt;Serverless and WebSockets are a bad pairing by default. Serverless assumes requests are stateless and short-lived; a WebSocket is neither. DOs give you a persistent, single-threaded process with in-memory state that survives across events. For a campus app where load is concentrated in a 20-minute morning window, you get rooms that spin up when needed and hibernate when not. No idle infrastructure, no connection hand-off problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Left
&lt;/h2&gt;

&lt;p&gt;A few open problems the current design doesn't address: drivers who cancel after a match is accepted, rooms that never get cleaned up after their departure window passes, and rate limiting on socket events. None of these tasks is difficult in principle. They just weren't the initial challenges.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>infrastructure</category>
      <category>backenddevelopment</category>
      <category>database</category>
    </item>
  </channel>
</rss>
