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.

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:
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:
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:
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:
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:
- Entitlements file: The
aps-environmentkey must be set todevelopmentorproduction. - Firebase initialization in AppDelegate:
FirebaseApp.configure()call is sufficient. APNs delegate methods are automatically managed by@react-native-firebase/messagingthrough method swizzling. - GoogleService-Info.plist: Configuration file downloaded from Firebase project. GCM must be enabled.
Note: We don't use
react-native-permissionsfor 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:
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:teamIdpair; 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:
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):
{
"aps": {
"alert": {
"title": "New opportunity in your area!",
"body": "There are new contents near you."
},
"sound": "default"
}
}Android (FCM v1):
{
"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:appId → appId → 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:
| Category | Fields |
|---|---|
| Identity | deviceId, platform, pushProvider, pushToken, userId |
| App | appId, appVersion, buildNumber |
| Device | phoneType, deviceModel, manufacturer, osName, osVersion, osApiLevel |
| Network | timezone, locale |
| Notification Permissions | permissionStatus, appNotificationsEnabled + platform flags |
| Location | latitude, 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:
- Domain event triggers: For example, a new campaign is published or a user enters a specific location. The relevant consumer service catches this event.
- Template resolves: The notification template is fetched from the database by language and type. Dynamic fields are filled with Mustache.
- In-App record created: A
NotificationUserrecord is added to the database. - Job queued: A
NotificationJobis created, priority calculated (Urgent → 0,Low → 4), and sent to RabbitMQ. - Queue worker processes: The message is received and routed by channel type (Email / SMS / InApp).
- Push sent: All registered devices of the user are fetched. Sent in parallel via
Promise.allSettled()— APNs for iOS, FCM for Android. - Result recorded:
Sent/PartiallySent/Failed/NoDevicestatus 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
| File | Where to Get | Purpose |
|---|---|---|
.p8 (APNs key) | Apple Developer → Keys | APNs JWT signing |
GoogleService-Info.plist | Firebase Console → iOS app | iOS Firebase connection |
google-services.json | Firebase Console → Android app | Android Firebase connection |
fcm.json (Service Account) | Google Cloud Console → IAM | FCM 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.