Pushing the Limits: Mastering Notifications with Apollo GraphQL and React Native Expo
A Step-by-Step Guide to Engaging Users with Real-Time Updates
In this blog post, we will explore how to send notifications from an Apollo GraphQL Server to a React native Expo application. We will implement registration of a push token, sending the notification from the server with and without delays, and retrieving a list of notifications that can fill out a user's notification feed. Whilst GraphQL has been used in this article for demonstration purposes, the same approach can be applied using any other networking protocol.
All code featured in this article can be found here: https://github.com/maxshugar/expo-notifications-graphql-integration-example
Step 1: Obtaining Android and iOS Credentials
Before we begin, we must obtain credentials for Android and iOS. Please refer to the expo documentation for the most up-to-date instructions on obtaining credentials for Android and iOS. Let me know if you require assistance with this step in the comments below.
Step 2: Creating an Expo Typescript Project
npx create-expo-app your-app-name -t expo-template-blank-typescript
Step 3: Installing libraries
npx expo install expo-notifications expo-device expo-constants
expo-notifications
is used to request a user's permission and to fetch theExpoPushToken
. It is not supported on an Android Emulator or an iOS Simulator.expo-device
is used to check whether the app is running on a physical device.expo-constants
is used to get theprojectId
value from the app config.
Step 4: Registering for Push Notifications
async function registerForPushNotificationsAsync(): Promise<string | undefined> {
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
}
let token;
if (Device.isDevice) {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
alert('Failed to get push token for push notification!');
return undefined;
}
token = (await Notifications.getExpoPushTokenAsync({ projectId: Constants.expoConfig?.extra?.eas.projectId })).data;
} else {
alert('Must use physical device for Push Notifications');
}
return token;
}
Before we can send push notifications, we must request permission from the device. The registerForPushNotificationsAsync
method sets the notification channel to default
on Android before checking whether we are running on a real device, not an emulator. If running on a real device, we check whether notification permissions have been granted. If not, we request permission from the user. Based on the result of finalStatus, we set the permissionGranted state, so that our components know whether the user has granted permission. If the user denies permission, we may want to lock sections of our app off until the user enables notifications.
Step 5: Notification Provider
The notification provider component below creates a React context for notifications with an initial undefined value. This context will allow any component wrapped in the provider to access the Expo push token, a flag for unread notifications, and a setter function for updating this flag.
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import React, { ReactNode, createContext, useContext, useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
interface NotificationContextType {
expoPushToken: string | undefined;
hasUnreadNotifications: boolean;
permissionGranted: boolean;
setHasUnreadNotifications: React.Dispatch<React.SetStateAction<boolean>>;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
interface NotificationProviderProps {
children: ReactNode;
}
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
const [expoPushToken, setExpoPushToken] = useState<string | undefined>(undefined);
const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
const [permissionGranted, setPermissionGranted] = useState(false);
const notificationListener: any = useRef();
const responseListener: any = useRef();
useEffect(() => {
registerForPushNotificationsAsync().then(token => setExpoPushToken(token));
notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
setHasUnreadNotifications(true);
});
responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
setHasUnreadNotifications(true);
});
return () => {
Notifications.removeNotificationSubscription(notificationListener.current);
Notifications.removeNotificationSubscription(responseListener.current);
};
}, []);
// async function registerForPushNotificationsAsync()...
return (
<NotificationContext.Provider value={{
expoPushToken,
hasUnreadNotifications,
permissionGranted,
setHasUnreadNotifications,
}}>
{children}
</NotificationContext.Provider>
);
};
export const useNotifications = (): NotificationContextType => {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
};
When the component mounts, registerForPushNotificationsAsync
is called to get the Expo push token and stores it using setExpoPushToken
, before registering two listeners:
notificationListener
listens for incoming notifications and setshasUnreadNotifications
to true when a notification is received.responseListener
listens for interactions (e.g., the user taps on the notification) and also setshasUnreadNotifications
to true in response.
The hasUnreadNotifications
flag will be used to trigger API updates. When the component unmounts, both listeners are unregistered to prevent memory leaks. Our useNotifications
hook can be used in all components that require access to the notification context such as a component making an API call that depends on the Expo Push Token. You can find more information on these two notification event listeners here.
Step 6: Defining our GraphQL Server
The code snippet below outlines the process of setting up a simple GraphQL server using Apollo Server, integrated with Expo's server SDK for managing push notifications. This server is designed to handle basic operations related to notifications, such as sending notifications, registering push tokens, and marking notifications as read.
import { ApolloServer, gql } from "apollo-server";
import Expo, { ExpoPushMessage } from "expo-server-sdk";
let expo = new Expo();
interface Notification {
id: string;
message: string;
read: boolean;
}
// This holds our registered push token for a single device.
// In a real world application we would cache push tokens to
// devices in our persistence layer to support multiple devices.
let pushToken: string;
let notifications: Notification[] = [
{ id: "1", message: "Hello World", read: false },
];
const typeDefs = gql`
type Notification {
id: ID!
message: String!
read: Boolean!
}
type Query {
notificationsFeed: [Notification]!
}
type Mutation {
sendNotification(message: String!, delay: Int): Boolean
registerPushToken(token: String!): Boolean
markNotificationAsRead(id: ID!): Boolean
}
`;
function delaySeconds(seconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
}
const resolvers = {
Query: {
notificationsFeed: () => {
console.log("Returning notifications...");
return notifications;
},
},
Mutation: {
sendNotification: async (
_: any,
{ message, delay }: { message: string; delay: number }
): Promise<Boolean> => {
if (!pushToken) {
console.error("No push token registered");
return false;
}
console.log("Sending notification...");
const newNotification: Notification = {
id: String(notifications.length + 1),
message,
read: false,
};
if (delay) {
await delaySeconds(delay);
}
if (pushToken && Expo.isExpoPushToken(pushToken)) {
const message: ExpoPushMessage = {
to: pushToken, // The recipient push token
sound: "default",
body: newNotification.message,
data: { withSome: "data" },
};
try {
await expo.sendPushNotificationsAsync([message]);
notifications.push(newNotification);
console.log("Notification sent successfully");
} catch (error) {
console.error("Error sending notification:", error);
return false;
}
}
return true;
},
registerPushToken: (_: any, { token }: { token: string }): boolean => {
pushToken = token;
return true;
},
markNotificationAsRead: (_: any, { id }: { id: string }): Notification => {
const notification = notifications.find(
(notification) => notification.id === id
);
if (!notification) {
throw new Error("Notification not found");
}
notification.read = true;
return notification;
},
},
};
const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
The GraphQL schema (typeDefs
) defines the data types and operations available in our API. It includes a Notification
type that mirrors our internal interface, a Query
type for fetching notifications (i.e., notificationsFeed
), and Mutation
types for actions like sending notifications (sendNotification
), registering push tokens (registerPushToken
), and marking notifications as read (markNotificationAsRead
). For those unfamiliar with GraphQL, the schema acts as a contract between the server and the client, outlining how clients can interact with the API. Resolvers are functions that handle the logic for fetching or modifying the data corresponding to each type of operation defined in the schema. In this server:
The
notificationsFeed
query resolver returns the current array of notifications.The
sendNotification
mutation constructs a new notification and uses the Expo SDK to send a push notification to a registered device, optionally after a delay.The
registerPushToken
mutation saves the client's push token to the server, which is crucial for sending push notifications to the correct device.The
markNotificationAsRead
mutation updates a notification'sread
status, indicating that the user has seen it.
While this server setup is simple, it demonstrates the core functionalities required for a mobile push notification system. In real-world applications, you would likely need to expand on this foundation to handle more complex scenarios, such as managing a large number of users and tokens, scaling the notification system, and ensuring security and privacy compliance. Additionally, integrating user authentication and managing user sessions would be necessary to securely register devices and send personalized notifications.
Step 7: Consuming our Apollo Server
This step demonstrates the practical application of the Apollo Client in a mobile setting, emphasizing the real-world utility of GraphQL in facilitating communication between the server and client side of an application.
import { useMutation, useQuery } from "@apollo/client";
import React, { useEffect, useState } from "react";
import { ActivityIndicator, Button, ScrollView, StyleSheet, Text, View } from "react-native";
import { useNotifications } from "../context/notification";
import { REGISTER_PUSH_TOKEN_MUTATION, SEND_NOTIFICATION_MUTATION } from "../gql/mutation";
import { NOTIFICATIONS_FEED_QUERY } from "../gql/query";
type Notification = {
id: string;
message: string;
read: boolean;
};
type NotificationsFeedData = {
notificationsFeed: Notification[];
};
export const FeedScreen: React.FC = () => {
const { expoPushToken, hasUnreadNotifications, setHasUnreadNotifications } = useNotifications();
const [feedback, setFeedback] = useState<string>("");
const [loadingAction, setLoadingAction] = useState<boolean>(false);
const { loading, error, data, refetch } = useQuery<NotificationsFeedData>(NOTIFICATIONS_FEED_QUERY);
const [registerPushToken] = useMutation(REGISTER_PUSH_TOKEN_MUTATION, {
onCompleted: (data) => {
if (data.registerPushToken === false) {
setFeedback("Error registering push token: No push token registered");
} else {
setFeedback("Push token registered successfully!");
}
setLoadingAction(false);
},
onError: (error) => {
setFeedback(`Error registering push token: ${error.message}`);
setLoadingAction(false);
},
});
const [sendNotification] = useMutation(SEND_NOTIFICATION_MUTATION, {
onCompleted: (data) => {
if (data.sendNotification === false) {
setFeedback("Error sending notification: No push token registered");
} else {
setFeedback("Notification sent successfully!");
}
setLoadingAction(false);
},
onError: (error) => {
setFeedback(`Error sending notification: ${error.message}`);
setLoadingAction(false);
},
});
useEffect(() => {
if (hasUnreadNotifications) {
console.log('Calling the API to mark notifications as read...');
setHasUnreadNotifications(false);
refetch();
}
}, [hasUnreadNotifications, refetch, setHasUnreadNotifications]);
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.tokenText}>Your expo push token: {expoPushToken}</Text>
<Text style={styles.status}>Has unread notifications: {hasUnreadNotifications ? 'Yes' : 'No'}</Text>
{loadingAction ? <ActivityIndicator size="small" color="#0000ff" /> : null}
<Text style={styles.feedback}>{feedback}</Text>
<Button
title="Register Push Token"
onPress={() => {
if (expoPushToken) {
setLoadingAction(true);
registerPushToken({ variables: { token: expoPushToken } });
}
}}
/>
<Button
title="Send Notification"
onPress={() => {
setLoadingAction(true);
sendNotification({ variables: { message: "Test notification from the server" } });
}}
/>
<Button
title="Send Notification with 5 second dely"
onPress={() => {
setLoadingAction(true);
sendNotification({ variables: { message: "Test notification from the server", delay: 5 } });
}}
/>
{loading ? <ActivityIndicator size="large" color="#0000ff" /> : null}
{error ? <Text style={styles.error}>Error loading notifications</Text> : null}
{data?.notificationsFeed.map(({ id, message, read }) => (
<View key={id} style={styles.notification}>
<Text>{`${message} - Read: ${read ? 'Yes' : 'No'}`}</Text>
</View>
))}
</ScrollView>
);
};
The FeedScreen
component showcases the integration of GraphQL operations within a React Native application using Apollo Client useMutation
and useQuery
hooks. This setup allows for a seamless interaction with the GraphQL server, enabling functionalities such as registering push tokens, sending notifications, and fetching a list of notifications. Functionalities Covered:
Register Push Token: This functionality allows the device to register its Expo push token with the server. It's crucial for the server to know where to send push notifications. The mutation
REGISTER_PUSH_TOKEN_MUTATION
is called with the push token obtained from our notification hook.Send Notification: This feature demonstrates sending a notification via the server, which can be done immediately or with a specified delay. It utilizes the
SEND_NOTIFICATION_MUTATION
mutation, showcasing the dynamic nature of GraphQL where arguments can be passed to modify the behavior of the server-side operation.Fetch Notifications: The
FeedScreen
component fetches and displays a list of notifications using theNOTIFICATIONS_FEED_QUERY
. This query provides real-time feedback to the user by displaying notifications, enhancing the interactive experience of the application.
The UI components used in this step, such as ScrollView
, ActivityIndicator
, Button
, and Text
, are standard React Native components that provide a simple yet effective user interface. The application employs conditional rendering to display loading indicators and feedback messages, ensuring the user is always informed about the state of the application.
Discussion on Scalability
As the user base grows, the volume of notifications can significantly increase, potentially impacting system performance. To ensure high availability and responsiveness, several strategies can be employed:
Asynchronous Processing and Batching: Persisting new notifications and processing them asynchronously in batches can prevent system bottlenecks. This also allows better control over the rate at which notifications are sent to Expo's servers. It is strongly recommended to limit notification sends to no more than 600 per second to avoid being throttled by Expo's rate limits.
Token Management: Since users may register multiple devices, storing push tokens at the user level, rather than the device level, ensures a consistent experience across devices. Push tokens can expire, so periodic validation and refresh processes should be in place to maintain token validity.
Retry and Error Handling: For failed notifications, implementing retries with exponential backoff or utilizing a dead letter queue for persistent failures ensures that the system handles errors gracefully without impacting overall performance.
User-Specific Rate Limiting: To reduce the risk of spamming, enforce rate limits on notifications sent to each individual user, ensuring they are not overwhelmed by excessive notifications.
These strategies help maintain a scalable, resilient system capable of handling increased loads efficiently.
Discussion on Networking Efficiency
The approach outlined in this blog post leverages Expo’s push notification service, which establishes a persistent TCP connection between the server and the device through Apple Push Notification Service (APNs) or Firebase Cloud Messaging (FCM). This connection is used to deliver push notifications to trigger a request-response mechanism for retrieving updated notifications via HTTP.
How APNs and FCM Work
Both APNs and FCM use highly optimized, system-level persistent TCP connections to communicate with devices. These services establish a single, shared connection at the operating system level, which is used to deliver notifications from multiple applications without needing separate connections for each app. This minimizes network overhead and optimizes power consumption, especially for mobile devices.
Apple Push Notification Service (APNs): APNs uses a persistent, encrypted connection over HTTP/2 with TLS for communication between Apple's servers and iOS devices. This integration with iOS ensures efficient battery usage while maintaining a long-lived connection that allows for near-instant delivery of push notifications.
Firebase Cloud Messaging (FCM): FCM uses a persistent TLS connection to Android devices which is managed by Google Play Services. This connection is shared across multiple apps and managed at the system level, allowing for the lightweight, efficient delivery of notifications. Here’s an informative article on how FCM works behind the scenes.
In both cases, the persistent TCP connection helps avoid the overhead of repeatedly establishing new connections, reducing both latency and resource usage on the device. The use of HTTP/2 enables multiplexing, where multiple notifications can be sent over a single connection, helping to conserve device resources such as battery life and network bandwidth.
Request-Response Method vs. WebSockets
In the request-response model used in this blog’s approach:
Push notifications trigger the client device to refetch the notification list via HTTP. This happens only when there is a need to update the notification feed, and no long-lived connection needs to be maintained.
This model is well-suited for low-frequency updates or scenarios where real-time communication is not critical, as it minimizes the power consumption when the app is in the background or not actively being used.
Efficiency of the Request-Response Method:
Power Efficiency: Since push notifications trigger an HTTP request only when new data is available, the app is generally idle most of the time, which saves power. The system-level optimizations of APNs/FCM handle the wake-up of the app only when necessary.
Simple to Scale: The request-response model is easy to scale for large user bases since notifications are handled asynchronously, and only active users (with new notifications) need to make requests to the server.
WebSockets Approach:
Real-Time Updates: WebSockets provide a persistent, full-duplex communication channel, enabling real-time updates without the need for repeated requests. This is more efficient for applications where real-time updates are crucial, such as in chat apps or collaborative tools.
Higher Resource Usage: Maintaining an open WebSocket connection consumes more power and network resources because the client must periodically send "ping" messages to keep the connection alive. This is especially costly when the app is in the background, where mobile operating systems may close the connection to save resources.
Key Trade-Offs:
WebSockets provide lower latency and continuous real-time communication but at the cost of higher power consumption, especially on mobile devices where multiple WebSocket connections may need to be maintained.
The request-response model is more power-efficient for apps where real-time updates are not critical. This approach takes advantage of APNs/FCM’s optimizations for background notifications and only opens HTTP connections when actively needed.
Conclusion
For most chat or notification-based applications that need real-time updates while in use but can tolerate slightly delayed notifications when backgrounded, the request-response model triggered by push notifications offers a balance between efficiency and real-time performance. It leverages the system-level optimizations of APNs/FCM for background operations, reducing unnecessary battery and data consumption. For more real-time critical systems, WebSockets provide an alternative but at the expense of increased resource use.
In this blog post, we've navigated through the process of integrating Apollo GraphQL Server with a React Native Expo application to manage and send mobile push notifications. I hope this guide has been informative and practical, providing you with the knowledge and tools needed to implement your own notification systems using React Native Expo. If you have any questions, comments, or wish to share your projects, feel free to leave a comment below. Happy coding!