DEV Community

Jules Sarah
Jules Sarah

Posted on

Expo Push Notifications: The Backend Half Nobody Finishes

Every Expo push notification tutorial ends the same way: you get a token on the device, paste it into Expo's push tool, a notification appears, done.

That's the first half. The second half is what runs in production: storing tokens per user, sending from your backend, checking receipts, and deleting dead tokens before Apple and Google start throttling you. Most apps skip it, and it's why "push works in testing but not for real users" is such a common bug report.

This is the backend half, using Supabase for storage and an Edge Function for sending. Two quick ground rules first:

  • Expo Go can't receive remote push since SDK 53. You need a development build (eas build --profile development).
  • A token identifies a device, not a user. One user can have three devices; one device can be handed to a new user after sign-out. Model it that way.

Step 1: A token table that survives real life

create table public.push_tokens (
  token       text primary key,
  user_id     uuid not null references auth.users(id) on delete cascade,
  platform    text not null check (platform in ('ios', 'android')),
  updated_at  timestamptz not null default now()
);

alter table public.push_tokens enable row level security;

create policy "own tokens" on public.push_tokens
  for all to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

The token is the primary key on purpose. When a device changes hands, an upsert on the token reassigns it to the new user instead of leaving a stale row that pushes the previous user's notifications to a stranger.

Step 2: Register the token on the device

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase';

export async function registerPushToken() {
  if (!Device.isDevice) return;                    // simulators don't get tokens

  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return;

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.DEFAULT,
    });
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });

  await supabase.from('push_tokens').upsert({
    token,
    user_id: (await supabase.auth.getUser()).data.user!.id,
    platform: Platform.OS,
    updated_at: new Date().toISOString(),
  });
}
Enter fullscreen mode Exit fullscreen mode

Call it after sign-in and on every app launch. On sign-out, delete the row for this device's token so the next user doesn't inherit it.

Step 3: Send from an Edge Function

Never send from the client: the Expo push endpoint doesn't need a secret, but your token table does, and you don't want app code deciding who gets notified.

// supabase/functions/send-push/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
import { Expo } from 'npm:expo-server-sdk';

const expo = new Expo();

Deno.serve(async (req) => {
  const { userId, title, body, data } = await req.json();

  const admin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!   // server-only; never in the app
  );

  const { data: rows } = await admin
    .from('push_tokens').select('token').eq('user_id', userId);

  const messages = (rows ?? [])
    .filter((r) => Expo.isExpoPushToken(r.token))
    .map((r) => ({ to: r.token, title, body, data, sound: 'default' }));

  const tickets = [];
  for (const chunk of expo.chunkPushNotifications(messages)) {
    tickets.push(...(await expo.sendPushNotificationsAsync(chunk)));
  }

  // Store ticket ids so a later job can fetch receipts
  await admin.from('push_tickets').insert(
    tickets
      .filter((t) => t.status === 'ok')
      .map((t, i) => ({ ticket_id: t.id, token: messages[i].to }))
  );

  return Response.json({ sent: tickets.length });
});
Enter fullscreen mode Exit fullscreen mode

Call it from a database trigger, a webhook, or another function with the user's ID. The service-role key stays in the function's environment.

Step 4: Check receipts (the step everyone skips)

A "ticket" only means Expo accepted the message. Whether Apple or Google delivered it comes back later as a receipt. If you never read receipts, you never learn that a token is dead, and you keep sending to it. Apple and Google both throttle apps that do that.

Run this as a scheduled function 15–30 minutes after sends:

// supabase/functions/check-receipts/index.ts
const { data: pending } = await admin
  .from('push_tickets').select('ticket_id, token').eq('checked', false).limit(1000);

for (const chunk of expo.chunkPushNotificationReceiptIds(pending.map((p) => p.ticket_id))) {
  const receipts = await expo.getPushNotificationReceiptsAsync(chunk);

  for (const [id, receipt] of Object.entries(receipts)) {
    if (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') {
      const row = pending.find((p) => p.ticket_id === id);
      await admin.from('push_tokens').delete().eq('token', row!.token);
    }
  }
  await admin.from('push_tickets').update({ checked: true }).in('ticket_id', chunk);
}
Enter fullscreen mode Exit fullscreen mode

DeviceNotRegistered means the user uninstalled, revoked permission, or the token expired. Delete it. That single loop is the difference between a push system that works for six months and one that quietly degrades.

Step 5: Handle taps

A notification that opens the app to the wrong screen feels broken. Read data and route:

import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';

const router = useRouter();

useEffect(() => {
  const sub = Notifications.addNotificationResponseReceivedListener((res) => {
    const { screen, id } = res.notification.request.content.data as any;
    if (screen === 'order') router.push(`/orders/${id}`);
  });
  return () => sub.remove();
}, []);
Enter fullscreen mode Exit fullscreen mode

Also check Notifications.getLastNotificationResponseAsync() on cold start, since the listener isn't mounted yet when a tap launches the app.

Checklist before you ship

  • [ ] Tokens stored per device with user reassignment on upsert
  • [ ] Token deleted on sign-out
  • [ ] Sends happen server-side with the service-role key in the function's env only
  • [ ] Tickets stored; receipts checked on a schedule; DeviceNotRegistered tokens deleted
  • [ ] Android notification channel created; Android 13+ runtime permission requested
  • [ ] APNs key uploaded and FCM v1 credentials configured in EAS
  • [ ] Tap handling covers both the running app and cold start
  • [ ] Tested on a physical device with a development build, not Expo Go

Where the scaffold comes from

The device-side code above, the token table, and the Supabase wiring are the same in every app. That's the part I let RapidNative generate: it produces the Expo app with expo-notifications, Supabase auth, and the data layer already connected, so the time goes into the sending logic and the receipt job, which are the parts that are actually yours.

What does your receipt-checking setup look like? I've seen everything from a cron job to "we didn't know receipts existed," and I'm curious where most teams land.

Top comments (0)