Push Notification Token Management Low-Level Design: FCM, APNs, and Device Registry

Push notification token management handles the lifecycle of device tokens issued by APNs (Apple) and FCM (Google): storing them when users register devices, routing notifications to the right tokens when sending, and cleaning up stale or invalid tokens that accumulate as users uninstall apps, change devices, or reinstall. Poor token management causes silent delivery failures — notifications are sent but never delivered because they target invalid tokens.

Core Data Model

CREATE TABLE DeviceToken (
    token_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id       BIGINT NOT NULL REFERENCES User(id),
    token         VARCHAR(512) NOT NULL,
    platform      VARCHAR(10) NOT NULL,  -- 'ios', 'android', 'web'
    app_version   VARCHAR(50),
    device_model  VARCHAR(100),
    is_active     BOOLEAN NOT NULL DEFAULT TRUE,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deactivated_at TIMESTAMPTZ,

    UNIQUE (token)  -- one row per token, tokens are globally unique
);

CREATE INDEX idx_devicetoken_user ON DeviceToken(user_id, is_active);
CREATE INDEX idx_devicetoken_token ON DeviceToken(token) WHERE is_active = TRUE;

Token Registration and Refresh

def register_device_token(user_id: int, token: str, platform: str, app_version: str):
    """
    Called on app launch or when FCM/APNs issues a new token.
    Handles: new devices, token rotation, app reinstall, user re-login.
    """
    db.execute("""
        INSERT INTO DeviceToken (user_id, token, platform, app_version, is_active)
        VALUES (%s, %s, %s, %s, TRUE)
        ON CONFLICT (token) DO UPDATE SET
            user_id = EXCLUDED.user_id,      -- re-login: associate token with new user
            is_active = TRUE,
            app_version = EXCLUDED.app_version,
            last_used_at = NOW(),
            deactivated_at = NULL
    """, [user_id, token, platform, app_version])

    # Deactivate old tokens for this user on the same platform
    # (user switched devices or reinstalled -- old token is stale)
    db.execute("""
        UPDATE DeviceToken
        SET is_active = FALSE, deactivated_at = NOW()
        WHERE user_id = %s AND platform = %s AND token != %s AND is_active = TRUE
          AND last_used_at < NOW() - INTERVAL '7 days'
    """, [user_id, platform, token])

Sending Notifications and Handling Invalid Tokens

def send_push_notification(user_id: int, title: str, body: str, data: dict = None):
    tokens = db.fetchall("""
        SELECT token_id, token, platform
        FROM DeviceToken
        WHERE user_id = %s AND is_active = TRUE
    """, [user_id])

    results = []
    for row in tokens:
        result = send_to_platform(row['platform'], row['token'], title, body, data)
        results.append((row['token_id'], result))

    # Handle platform responses
    handle_send_results(results)
    return results

def handle_send_results(results: list):
    for token_id, result in results:
        if result.status == 'InvalidRegistration' or result.status == 'NotRegistered':
            # Token is permanently invalid (app uninstalled, token revoked)
            db.execute("""
                UPDATE DeviceToken
                SET is_active = FALSE, deactivated_at = NOW()
                WHERE token_id = %s
            """, [token_id])
        elif result.status == 'Unregistered':
            # APNs: token valid but app not installed -- deactivate
            db.execute("""
                UPDATE DeviceToken SET is_active = FALSE, deactivated_at = NOW()
                WHERE token_id = %s
            """, [token_id])
        elif result.status == 'success':
            db.execute("""
                UPDATE DeviceToken SET last_used_at = NOW() WHERE token_id = %s
            """, [token_id])

Fanout to Multiple Devices

def send_to_user_segment(user_ids: list[int], title: str, body: str):
    """Send to many users efficiently using batch API calls."""
    # Fetch all active tokens for the segment in one query
    tokens = db.fetchall("""
        SELECT user_id, token, platform
        FROM DeviceToken
        WHERE user_id = ANY(%s) AND is_active = TRUE
    """, [user_ids])

    # Group by platform for batch API calls
    fcm_tokens = [t['token'] for t in tokens if t['platform'] == 'android']
    apns_tokens = [t for t in tokens if t['platform'] == 'ios']

    # FCM supports up to 500 tokens per batch request
    for batch in chunk(fcm_tokens, 500):
        fcm_client.send_multicast(tokens=batch, title=title, body=body)

    # APNs requires individual HTTP/2 requests but supports connection multiplexing
    with apns_http2_session() as session:
        for token_row in apns_tokens:
            session.send_async(token_row['token'], title=title, body=body)

Notification Preferences Integration

def should_send_push(user_id: int, notification_type: str) -> bool:
    """Check user's notification preferences before sending."""
    pref = db.fetchone("""
        SELECT push_enabled, quiet_hours_start, quiet_hours_end, timezone
        FROM NotificationPreference
        WHERE user_id = %s AND notification_type = %s
    """, [user_id, notification_type])

    if not pref or not pref['push_enabled']:
        return False

    # Respect quiet hours
    if pref['quiet_hours_start'] is not None:
        user_time = datetime.now(pytz.timezone(pref['timezone']))
        if is_in_quiet_hours(user_time.time(), pref['quiet_hours_start'], pref['quiet_hours_end']):
            return False
    return True

Key Interview Points

  • Tokens are globally unique — the UNIQUE constraint on token means each physical token is represented by exactly one row. Re-registration via ON CONFLICT handles token rotation, reinstalls, and user switches on the same device.
  • Always process platform error codes immediately: InvalidRegistration and NotRegistered from FCM/APNs mean the token is permanently dead — deactivate it immediately or future sends are wasted API calls.
  • A user with 5 devices gets 5 token rows. All 5 receive the notification unless explicitly filtered (e.g., send only to the most recently active device).
  • FCM batch API (up to 500 tokens per request) drastically reduces API call volume for broadcast notifications. Use it for segment fanouts.
  • Token hygiene: deactivate tokens not used in 90 days. A growing table of stale tokens slows down user lookups and sends failures to dead endpoints.
  • Never store the raw token in application logs — tokens are sensitive credentials that allow sending notifications to a user’s device without further authentication.

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you handle token rotation when FCM or APNs issues a new token?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”FCM and APNs can issue a new token for a device at any time — after app reinstall, OS update, or token expiration. The app receives the new token via the FCM onTokenRefresh callback (Android) or APNs didRegisterForRemoteNotificationsWithDeviceToken (iOS) and must call your registration API with the new token. Your server uses ON CONFLICT (token) DO UPDATE to handle this idempotently: if the token already exists (same device registered again), update the user_id, last_used_at, and app_version. If it is a new token for the same user, insert a new row. The old token becomes stale — deactivate it via: UPDATE DeviceToken SET is_active=FALSE WHERE user_id=%s AND platform=%s AND token != %s AND last_used_at < NOW() – INTERVAL ‘7 days’.”}},{“@type”:”Question”,”name”:”What error codes indicate an invalid token and what should you do with them?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”FCM error codes for invalid tokens: NotRegistered (app was uninstalled or token was revoked — permanently invalid, deactivate immediately), InvalidRegistration (malformed token — deactivate). Transient errors: Unavailable or InternalServerError (retry with backoff — do not deactivate). APNs error codes: BadDeviceToken (invalid token — deactivate), Unregistered (app uninstalled — deactivate), ExpiredProviderToken (your server’s signing key expired — fix your server config, not a device issue). The critical rule: on NotRegistered/BadDeviceToken/Unregistered, immediately mark the token as inactive in your database. Continuing to send to dead tokens wastes API quota, can cause rate limiting, and pollutes your delivery metrics.”}},{“@type”:”Question”,”name”:”How do you send a notification to all of a user’s devices without N+1 queries?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Fetch all active tokens for the user in a single query: SELECT token, platform FROM DeviceToken WHERE user_id=%s AND is_active=TRUE. This returns all devices (iOS, Android, web) in one database round-trip. Then group by platform and use batch APIs: FCM supports send_multicast with up to 500 tokens per call (use it for Android tokens); APNs requires one HTTP/2 request per token but supports concurrent requests over a single persistent connection. Send to all devices simultaneously (fire-and-forget for most notification types); if you want to send only to the most recently active device, add ORDER BY last_used_at DESC LIMIT 1 to the query.”}},{“@type”:”Question”,”name”:”How do you implement quiet hours for push notifications?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store quiet hours in a NotificationPreference table: quiet_hours_start (e.g., 22:00), quiet_hours_end (e.g., 08:00), and timezone (e.g., America/New_York). Before sending, check: convert current UTC time to the user’s timezone, then check if it falls within the quiet window. Handle the midnight wrap-around: if start > end (e.g., 22:00 to 08:00), the quiet window spans midnight and requires two comparisons (time >= 22:00 OR time < 08:00). For notifications that should not be dropped (urgent alerts, security codes), bypass quiet hours. For low-priority notifications (marketing, weekly digests), queue them and deliver when quiet hours end. A scheduled job at quiet_hours_end can flush the queued notifications.”}},{“@type”:”Question”,”name”:”How do you scale push notification delivery to millions of users?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”For large-scale fanouts (breaking news, product announcements to all users), you cannot call the push API synchronously in the request path — it would block for minutes. Architecture: (1) Write the notification job to a queue (Kafka, SQS) with the target audience (all users, segment, or specific user_ids). (2) Worker pool reads from the queue, fetches device tokens in batches (1000 user_ids at a time via SELECT … WHERE user_id = ANY(…)), and calls FCM/APNs batch APIs. (3) FCM send_multicast handles up to 500 tokens per request — group all Android tokens and batch-send. (4) Multiple worker instances run in parallel, each handling a partition of the audience. A fanout to 10M users with 2 tokens each = 20M API calls — at 500 tokens/call = 40,000 FCM calls, achievable in minutes with 10 workers.”}}]}

Push notification token management and delivery system design is discussed in Meta system design interview questions.

Push notification token management and APNs integration is covered in Apple system design interview preparation.

Push notification token management and mobile delivery design is discussed in Snap system design interview guide.

Scroll to Top