Back to Blog
React NativeNestJSPush Notifications

Location-Based Push Notifications: Building Your Own Notification Infrastructure from Scratch

August 27, 202612 min read
Location-Based Push Notifications: Building Your Own Notification Infrastructure from Scratch

When you want to set up a push notification system in your mobile app, the first things that come to mind are usually third-party services like OneSignal, Firebase Cloud Messaging, Braze, or Dengage. These tools are tempting for a quick start. But when you say "send notifications based on user's real-time location," things change — none of them offer this out of the box.

In applications that need Precise Location data — career, order tracking, delivery — this need is inevitable: when a user is in a specific area, you want to instantly notify them about new job listings nearby, local campaigns, or delivery status. In this post, I'll walk you through how I built a system from scratch that connects directly to APNs and FCM APIs, collects real-time location from devices, and sends geo-targeted notifications with PostGIS. We'll examine the architecture on both the React Native (mobile) and NestJS (backend) sides, along with the code.

When Should You Build Your Own Notification System?

  • Location-based targeting: Services like OneSignal and Braze offer geofence-based notifications: you draw an area in advance, and it triggers when a user enters that area. But if the goal is to find nearby users on the server side and automatically send push notifications when new content is published (job listing, campaign, delivery point), no service offers this "content-triggered, server-side geo-matching" flow out of the box.
  • Cost control: Third-party services charge per device or per notification. Building your own notification infrastructure with APNs and FCM is completely free.
  • Data ownership: User device information, location data, and notification preferences are entirely in our own database.
  • Flexibility: We can solve needs like silent notifications and multi-app support independently from third-party interfaces.
  • No vendor lock-in: We can migrate or change the infrastructure whenever we want.

Architecture Overview

In this post, I'll walk through the system using React Native (mobile), GraphQL (API gateway), and NestJS (notification microservice), with RabbitMQ as the messaging layer. You can easily adapt the approach to your own tech stack.

Architecture Overview

Mobile Side: Device Registration and Token Management

The mobile side of the push notification system is built on three core responsibilities: requesting permission, obtaining platform tokens, and registering with the backend. We manage all of this in a React Provider component.

Stable Device Identity

Each device needs a unique record in the backend. But DeviceInfo.getUniqueId() can change in some cases after app deletion/reinstallation. That's why we use a two-layer approach:

typescript
const KEYCHAIN_SERVICE = 'company.device.id';

export const getOrCreatePushDeviceId = async () => {
  // 1. Try from Keychain (persists even after app deletion)
  const stored = await Keychain.getGenericPassword({
    service: KEYCHAIN_SERVICE
  });
  if (stored?.password) return stored.password;

  // 2. Get from OS, save to Keychain
  const osDeviceId = await DeviceInfo.getUniqueId();
  if (osDeviceId) {
    await Keychain.setGenericPassword(KEYCHAIN_ACCOUNT, osDeviceId, {
      service: KEYCHAIN_SERVICE,
      accessible: Keychain.ACCESSIBLE.ALWAYS,
    });
    return osDeviceId;
  }
  return null;
};

On iOS, Keychain preserves data even after app deletion (when reinstalled with the same provisioning profile). On Android, similar persistence is achieved through Keystore. This way, even if the user deletes and reinstalls the app, they're recognized with the same device ID.

Getting Platform Token: APNs vs FCM

The push token acquisition process works differently on iOS and Android. On iOS, you first need to register with APNs and get the APN token. Since this token isn't always immediately ready, we add a retry mechanism:

typescript
export const getPushToken = async () => {
  if (Platform.OS === 'ios') {
    const status = await messaging().hasPermission();
    if (status === AuthorizationStatus.NOT_DETERMINED) {
      await messaging().requestPermission();
    }

    await messaging().registerDeviceForRemoteMessages();
    const apnsToken = await getApnsTokenWithRetry();
    return { pushProvider: 'APNS', pushToken: apnsToken };
  }

  // Android: FCM token is obtained directly
  const fcmToken = await messaging().getToken();
  return { pushProvider: 'FCM', pushToken: fcmToken };
};

Why doesn't the APNs token come immediately? On iOS, the registerDeviceForRemoteMessages() call is asynchronous and the token arrives when prepared by the system. During cold start, this can take a few hundred milliseconds. That's why we make 5 attempts at [0, 400, 800, 1500, 2500] ms intervals.

PushProvider: Orchestration Layer

The component that brings all these pieces together is PushProvider. It triggers device registration when the app opens, when the token refreshes, when the app comes to the foreground, or when the session changes:

tsx
export const PushProvider = ({ children }) => {
  const { tokens, user } = useAppSelector(state => state.auth);
  const location = useAppSelector(state => state.device.location);

  // Register on app launch
  useEffect(() => {
    requestNotificationPermission();
    syncDevice(undefined, { reason: 'boot' });
  }, []);

  // When Firebase token refreshes
  useEffect(() => {
    return messaging().onTokenRefresh(() => {
      syncDevice(undefined, { reason: 'token-refresh' });
    });
  }, []);

  // Set userId to null on logout
  useEffect(() => {
    if (!isAuthenticated) {
      syncDevice(null, { reason: 'auth-reset' });
    }
  }, [isAuthenticated]);

  // When app comes to foreground (30s cooldown)
  useEffect(() => {
    const sub = AppState.addEventListener('change', state => {
      if (state !== 'active') return;
      if (Date.now() - lastCheck < 30000) return;
      syncDevice(undefined, { reason: 'app-active' });
    });
    return () => sub.remove();
  }, []);

  return <>{children}</>;
};

Deduplication: Preventing Unnecessary API Calls

On each sync, we combine all device information (model, OS, permissions, location, token) and create a "signature." If this signature matches the previous record, no API call is made:

typescript
const signature = createPayloadSignature(payload);
const lastSignature = await storage.getItem(
  STORAGE_KEYS.PUSH_LAST_SYNC_SIGNATURE
);

if (lastSignature === signature && registrationId) {
  return payload; // No change, API call skipped
}

When creating the signature, the syncedAt field is zeroed out. This way, only real data changes (new token, location update, permission change) trigger a record.

Apollo Client Integration

We send the device identity with every GraphQL request to the backend. By adding a custom middleware to Apollo Client, a cx-device-id header is added to every request. If there's no registration ID, device synchronization is automatically triggered.

Retry Strategy

For network errors or token-not-ready situations, we apply an exponential backoff + jitter strategy. Wait times: 5s → 15s → 45s → 2min → 5min. A random multiplier between 0.85x–1.15x is applied to each duration. This prevents multiple devices from retrying simultaneously (thundering herd).

Native Configuration

iOS Side:

  1. Entitlements file: The aps-environment key must be set to development or production.
  2. Firebase initialization in AppDelegate: FirebaseApp.configure() call is sufficient. APNs delegate methods are automatically managed by @react-native-firebase/messaging through method swizzling.
  3. GoogleService-Info.plist: Configuration file downloaded from Firebase project. GCM must be enabled.

Note: We don't use react-native-permissions for notification permission. On iOS, Firebase Messaging handles its own permission management. For Android 13+ (API 33), PermissionsAndroid.request(POST_NOTIFICATIONS) is sufficient.

Android Side:

The google-services.json file and a few manifest permissions are needed. The tools:remove="android:maxSdkVersion" in the WAKE_LOCK permission is critical: it's needed for Firebase's ReactNativeFirebaseMessagingReceiver component to process notifications in the background on API 26+.

Backend: Direct Connection to APNs and FCM

I separated the notification service as an independent microservice from the main application. Communication with the main app happens through asynchronous messaging via RabbitMQ, so notification delivery never blocks the core business flow.

APNs Integration: Direct Connection via HTTP/2

We connect to Apple's APNs service without using any SDK or wrapper, using Node.js's native http2 module. We use JWT (ES256) for authentication:

typescript
import * as http2 from 'http2';
import * as jwt from 'jsonwebtoken';

private getJWT(creds: ApnsCredentials): string {
  const privateKey = fs.readFileSync(creds.keyPath, 'utf8');
  return jwt.sign({}, privateKey, {
    algorithm: 'ES256',
    keyid: creds.keyId,
    issuer: creds.teamId,
    expiresIn: '1h',
  });
}

async send(deviceToken, payload) {
  const host = isProduction
    ? 'api.push.apple.com'
    : 'api.sandbox.push.apple.com';

  const client = http2.connect(`https://${host}`);

  const req = client.request({
    ':method': 'POST',
    ':path': `/3/device/${deviceToken}`,
    'authorization': `bearer ${jwt}`,
    'apns-topic': creds.bundleId,
    'apns-push-type': payload.silent ? 'background' : 'alert',
    'apns-priority': payload.silent ? '5' : '10',
  });
}

JWT caching: APNs JWT tokens are valid for 1 hour. We cache them for 50 minutes, avoiding re-signing for every notification. The cache key is the keyId:teamId pair; supporting different credentials for multiple apps.

FCM Integration: v1 HTTP API

For Android notifications, we use the FCM v1 API. Unlike the Legacy API (which worked with a server key), the v1 API requires OAuth2 authentication and offers a richer payload structure:

typescript
import { GoogleAuth } from 'google-auth-library';

async send(deviceToken, payload) {
  const auth = new GoogleAuth({
    keyFile: creds.serviceAccountPath,
    scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
  });

  const accessToken = await auth.getClient().getAccessToken();

  await fetch(
    `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({ message: { token: deviceToken, ... } }),
    }
  );
}

Payload Structure: iOS vs Android

Each platform expects a different payload format. The backend produces two different structures from the same content:

iOS (APNs):

json
{
  "aps": {
    "alert": {
      "title": "New opportunity in your area!",
      "body": "There are new contents near you."
    },
    "sound": "default"
  }
}

Android (FCM v1):

json
{
  "message": {
    "token": "fcm_token...",
    "notification": {
      "title": "New opportunity in your area!",
      "body": "There are new contents near you."
    },
    "android": {
      "priority": "high",
      "notification": { "sound": "default" }
    }
  }
}

Multi-App Support: Credential Resolver

Our system can send notifications to multiple apps. We manage this with the PushCredentialResolver service. Resolution priority: platform:appIdappId → global environment variables. If the config file is missing or corrupt, the system falls back to old behavior (global env). Existing notifications never break.

Data Collected During Device Registration

Comprehensive information is collected from the device and sent to the backend on each sync:

CategoryFields
IdentitydeviceId, platform, pushProvider, pushToken, userId
AppappId, appVersion, buildNumber
DevicephoneType, deviceModel, manufacturer, osName, osVersion, osApiLevel
Networktimezone, locale
Notification PermissionspermissionStatus, appNotificationsEnabled + platform flags
Locationlatitude, longitude, accuracy, capturedAt

The backend stores this data with Prisma in PostgreSQL. Location data is stored as a time series, keeping the last 10 snapshots.

Notification Pipeline: End-to-End Flow

The process from notification trigger to reaching the user's phone:

  1. Domain event triggers: For example, a new campaign is published or a user enters a specific location. The relevant consumer service catches this event.
  2. Template resolves: The notification template is fetched from the database by language and type. Dynamic fields are filled with Mustache.
  3. In-App record created: A NotificationUser record is added to the database.
  4. Job queued: A NotificationJob is created, priority calculated (Urgent → 0, Low → 4), and sent to RabbitMQ.
  5. Queue worker processes: The message is received and routed by channel type (Email / SMS / InApp).
  6. Push sent: All registered devices of the user are fetched. Sent in parallel via Promise.allSettled() — APNs for iOS, FCM for Android.
  7. Result recorded: Sent / PartiallySent / Failed / NoDevice status is written to the job record.

Retry Mechanism

A scanner runs every 60 seconds for failed deliveries. It prevents double processing with atomic claiming. Max 3 attempts with 5-minute intervals. Jobs older than 24 hours expire.

Location-Based Notifications: The System's Differentiator

The strongest aspect of this system, and the main feature that sets us apart from third-party services, is the infrastructure for collecting real-time location data and sending notifications based on it.

On the mobile side, location information is sent along with each device registration, if the user has granted permission. Location is updated every time the app comes to the foreground (with a 30s cooldown). So even if the user changes cities, the system catches this.

On the backend side, the PostGIS extension is active in PostgreSQL. Location snapshots are stored as a time series (last 10 snapshots). This way, we can access not just "where they are now" but also "where they've been in the last X hours."

How does it work in practice? The application already lists content by distance using geo_distance queries on Elasticsearch. The same logic applies on the notification side: when new content is published, users near that content's location are filtered and automatic push notifications are sent. Wherever the user is, they're instantly notified about relevant content in their area.

Building this flow with OneSignal, Braze, Dengage, or Firebase's managed layer is not possible. Because none of them offer a pipeline that collects real-time location from devices and combines it with notification targeting.

Required Files and Credentials

FileWhere to GetPurpose
.p8 (APNs key)Apple Developer → KeysAPNs JWT signing
GoogleService-Info.plistFirebase Console → iOS appiOS Firebase connection
google-services.jsonFirebase Console → Android appAndroid Firebase connection
fcm.json (Service Account)Google Cloud Console → IAMFCM v1 API OAuth2 access

.p8 vs .p12: Apple offers two types of certificates. .p12 (certificate-based) is the old method and needs annual renewal. .p8 (token-based / JWT) is newer, doesn't expire, and can work with a single key for all apps. We use .p8.

Conclusion

Building your own push notification system may seem daunting at first, but APNs and FCM's HTTP APIs are actually quite simple and well-documented. The critical parts (token management, retry strategies, deduplication, and multi-device support) are solved at the business logic level.

But the real gain is this: you can build features like location-based notifications that no third-party service offers out of the box. Your user data stays with you, the pipeline is entirely under your control, and you're not paying monthly per-device fees. The downside is that maintenance responsibility is entirely yours. But in a production-grade application, this level of understanding is inevitable.

If you're considering building a similar system that combines location data with notifications, I hope this post serves as a good starting point. Feel free to reach out if you have any questions or suggestions.