Before push notifications, keeping users engaged with a mobile app meant hoping they'd open it. Email was the only re-engagement channel. SMS was expensive and invasive. Apps were islands - once the user left, you had no way to reach them until they came back.
Apple introduced Push Notification Service (APNS) with iPhone OS 3.0 in June 2009. Google responded with Cloud to Device Messaging (C2DM) in 2010, then Google Cloud Messaging (GCM) in 2012. Suddenly apps could reach users at any time.
The implementation was not simple.
How APNS Worked
Apple Push Notification Service used a persistent TLS connection between your backend server and Apple's servers. You sent a binary payload; Apple delivered it to the device.
Step 1: The device registers
// AppDelegate.m - iOS 5 era registration
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Register for remote notifications
[[UIApplication sharedApplication] registerForRemoteNotificationTypes:
UIRemoteNotificationTypeBadge |
UIRemoteNotificationTypeSound |
UIRemoteNotificationTypeAlert
];
return YES;
}
// Called if registration succeeds - send token to YOUR server
- (void)application:(UIApplication *)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
// Convert token to hex string
NSString *token = [deviceToken.description
stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"<>"]];
token = [token stringByReplacingOccurrencesOfString:@" " withString:@""];
// POST to your backend API
[self registerTokenWithServer:token];
}
- (void)application:(UIApplication *)application
didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
NSLog(@"Push registration failed: %@", error.localizedDescription);
}
The device token was a 32-byte identifier assigned by Apple to your app on a specific device. It changed if the user wiped their device or reinstalled the app. Your server had to store these tokens and handle the churn.
Step 2: Your server connects to APNS
APNS required a persistent binary TCP connection on port 2195, authenticated with an SSL certificate issued by Apple. No REST API. Raw binary protocol:
<?php
// PHP backend - sending via APNS binary protocol (simplified)
class APNSSender {
private $socket;
private $certPath;
public function connect() {
$ctx = stream_context_create();
stream_context_set_option($ctx, 'ssl', 'local_cert', $this->certPath);
stream_context_set_option($ctx, 'ssl', 'passphrase', 'your-cert-passphrase');
// Production: gateway.push.apple.com:2195
// Sandbox: gateway.sandbox.push.apple.com:2195
$this->socket = stream_socket_client(
'ssl://gateway.push.apple.com:2195',
$errno, $errstr, 60,
STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT,
$ctx
);
if (!$this->socket) {
throw new Exception("APNS connection failed: $errstr ($errno)");
}
}
public function send($deviceToken, $message, $badge = 0, $sound = 'default') {
$payload = json_encode([
'aps' => [
'alert' => $message,
'badge' => $badge,
'sound' => $sound,
]
]);
// APNS binary frame format
$tokenBinary = pack('H*', $deviceToken); // 32 bytes
$payloadLength = strlen($payload);
// Enhanced notification format (added item ID and expiry for error feedback)
$notification = pack('C', 1) // Command (1 = enhanced)
. pack('N', time()) // Identifier
. pack('N', time() + 86400) // Expiry: 24 hours
. pack('n', 32) . $tokenBinary // Device token (length + data)
. pack('n', $payloadLength) . $payload; // Payload (length + data)
fwrite($this->socket, $notification);
}
}
This was fragile. APNS silently dropped the connection on error - invalid tokens, malformed payloads - with a brief error response before closing. If you didn't read the error response immediately after each write, you'd miss it. Many implementations sent thousands of notifications before discovering the connection had died after the first bad token.
APNS Feedback Service
Apple ran a separate "feedback service" on port 2196. It returned a list of device tokens that should be removed - devices where the user had uninstalled your app:
public function getFeedback() {
$ctx = stream_context_create();
stream_context_set_option($ctx, 'ssl', 'local_cert', $this->certPath);
$fp = stream_socket_client(
'ssl://feedback.push.apple.com:2196',
$errno, $errstr, 60, STREAM_CLIENT_CONNECT, $ctx
);
$tokens = [];
while (!feof($fp)) {
$data = fread($fp, 38); // Timestamp(4) + TokenLength(2) + Token(32)
if (strlen($data) === 38) {
$parts = unpack('Ntimestamp/ntoken_length/H*token', $data);
$tokens[] = [
'token' => $parts['token'],
'timestamp' => $parts['timestamp']
];
}
}
fclose($fp);
return $tokens;
}
You were supposed to poll this service daily and purge invalid tokens from your database. Apps that didn't do this accumulated huge lists of dead tokens, wasted bandwidth, and eventually got rate-limited by Apple.
Android: GCM Was Different
Google Cloud Messaging used a REST API. Much simpler from the server side:
// PHP - Sending via GCM REST API
function sendGCM($registrationId, $title, $message) {
$apiKey = 'your-gcm-server-api-key';
$payload = json_encode([
'registration_ids' => [$registrationId],
'data' => [
'title' => $title,
'message' => $message,
'time' => time(),
],
'collapse_key' => 'update', // Replace pending notifications of same key
'time_to_live' => 86400, // 24 hours
]);
$ch = curl_init('https://android.googleapis.com/gcm/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: key=' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
// GCM returns canonical IDs for updated tokens
if (isset($response['results'][0]['registration_id'])) {
$newId = $response['results'][0]['registration_id'];
// Update token in database
updateDeviceToken($registrationId, $newId);
}
return $response;
}
GCM's canonical_id response was the equivalent of APNS feedback - if the token had changed, GCM told you the new one in the send response. Much cleaner design.
On the Android side:
// Android - handling GCM in a BroadcastReceiver
public class GCMReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if ("com.google.android.c2dm.intent.RECEIVE".equals(action)) {
String title = intent.getStringExtra("title");
String message = intent.getStringExtra("message");
showNotification(context, title, message);
}
}
private void showNotification(Context context, String title, String body) {
NotificationManager nm = (NotificationManager)
context.getSystemService(Context.NOTIFICATION_SERVICE);
Notification.Builder builder = new Notification.Builder(context)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true);
nm.notify(1, builder.build());
}
}
The Database Schema
Storing device tokens required handling the multi-platform, multi-device reality:
CREATE TABLE push_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
platform ENUM('ios', 'android') NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
app_version VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (user_id),
INDEX (platform)
);
-- Track notification history for analytics
CREATE TABLE push_notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
token_id INT NOT NULL,
message TEXT,
payload TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status ENUM('sent', 'failed', 'invalid_token') DEFAULT 'sent'
);
One user could have multiple devices. One device could have multiple apps (dev and production builds had different tokens). Token rotation meant updating rather than inserting duplicate users.
What Users Actually Responded To
The engagement data from our first year of push notifications:
| Message Type | Open Rate |
|---|---|
| "Check out our new arrivals!" | 1.8% |
| "Your order has shipped" | 34% |
| "[Username], you have a new message from [Name]" | 41% |
| "Flash sale: 20% off in the next 2 hours" | 12% |
| Weekly "top content" digest | 6% |
Transactional notifications (order shipped, new message from a specific person) had dramatically higher open rates than promotional blasts. This was obvious in retrospect, less obvious before seeing the data.
The lesson: push notifications were a privilege, not a marketing channel. Users who felt spammed disabled notifications; iOS required explicit permission and users used it. Apps that blasted promotional messages saw notification permissions drop to 40-50%. Apps that used notifications only for genuinely timely personal events kept permission rates above 80%.
How It Changed
Firebase Cloud Messaging (FCM) unified iOS and Android under a single API in 2016. Apple replaced the binary protocol with an HTTP/2 API in the same year. The architectural complexity of maintaining two separate pipelines, two certificate management processes, and two feedback polling jobs collapsed into one REST endpoint.
The best reminder of 2012: genuinely hard infrastructure problems eventually get standardized. Appreciate the complexity while it exists - it teaches you things. Then appreciate when someone builds an abstraction that makes it irrelevant.
Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.
Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development