Skip to main content

Keep Scheduled Local Notifications Aligned With the App Language

2026-10-19

Keep Scheduled Local Notifications Aligned With the App Language

Local notification localization breaks when an app schedules rendered text, then the user changes the app language before the alert fires. The settings screen may show Spanish while tomorrow's reminder still arrives in English. Repeating reminders can keep the old copy for weeks. Fixing the visible interface does not update requests already owned by iOS or records already handed to Android's scheduling layer.

The reliable design stores a semantic reminder record, renders notification text at a defined point, and rebuilds stale requests when locale or translation state changes. This article gives mobile engineers a cross-platform data model, scheduling sequence, rescheduling rules, failure handling, and a device test plan.

Why scheduled notification text becomes stale

A scheduled reminder crosses several systems. Product code creates the reminder, the localization layer resolves a message, an operating-system scheduler holds delivery state, and a notification surface displays the result while the app may be suspended. Those systems do not automatically share one live locale value.

Apple's archived guide for scheduling and handling local notifications shows the app creating notification content before adding a request to the system. The current UNMutableNotificationContent object carries fields such as title, body, category, and user information. Once your app resolves reminder.body to English and schedules that content, a later in-app locale switch does not rewrite the pending request.

Android offers another timing option. An alarm can wake app code at the intended time, and that code can build the notification using the current effective locale. The Android guide to scheduling alarms explains that alarms operate outside the lifetime of the app. Delivery still depends on the selected alarm type, platform restrictions, and the receiver path working correctly.

A practitioner report about a local notification using the device language instead of the selected app language documents an older iOS API, so it is not a current platform guarantee. It does show the underlying ownership bug: the app and notification system can resolve language through different paths.

Store meaning before rendered text

Do not make an operating-system request the only record of a reminder. Keep a canonical record in app storage with enough information to reproduce the request.

type ReminderRecord = {
  id: string;
  messageKey: string;
  arguments: Record<string, string | number>;
  fireAt: string;
  timeZonePolicy: "fixed-instant" | "local-wall-time";
  recurrence: null | "daily" | "weekly";
  route: { screen: string; entityId?: string };
  localePolicy: "follow-app" | "snapshot";
  renderedLocale?: string;
  translationVersion?: string;
  platformRequestId?: string;
};

messageKey identifies the approved message. arguments contains values needed for pluralization or formatting. fireAt, timeZonePolicy, and recurrence describe when the reminder should fire without hiding time behavior inside a translated sentence. route identifies the destination without baking a language into a fragile URL.

The locale policy is an explicit product decision:

  • follow-app means a pending reminder should use the app's effective language at delivery. Rebuild it when that language changes if the platform stores rendered text.
  • snapshot means the reminder preserves the language selected when the user created it. Use this only when preserving that language is part of the reminder's meaning, and show that choice in the UI.

Most personal reminders should follow the current app language. A shared or exported reminder may need a snapshot because its author selected wording for another recipient. Do not let framework defaults make this decision.

The translation version lets a release repair previously scheduled copy. A locale can stay unchanged while a translator fixes a mistranslation or a placeholder bug. Comparing both locale and translation version catches that case.

Choose the render point by platform

There are two valid render points: scheduling time and delivery time. Pick one deliberately for each platform path.

Render before scheduling on iOS

For a normal iOS local notification, resolve the title and body before creating the pending request. Store the semantic reminder first, then create UNMutableNotificationContent, attach a stable reminder ID in user information, and submit the request. Keep the platform request identifier equal to or derived from the canonical reminder ID.

func schedule(_ reminder: ReminderRecord, locale: Locale) async throws {
    let content = UNMutableNotificationContent()
    content.title = translations.text(
        key: "reminder.title",
        locale: locale
    )
    content.body = translations.text(
        key: reminder.messageKey,
        arguments: reminder.arguments,
        locale: locale
    )
    content.userInfo = ["reminder_id": reminder.id]
    content.categoryIdentifier = "REMINDER"

    let trigger = makeTrigger(reminder)
    let request = UNNotificationRequest(
        identifier: reminder.id,
        content: content,
        trigger: trigger
    )
    try await UNUserNotificationCenter.current().add(request)
}

The app can reproduce this request because the database holds the key, arguments, timing policy, and route. When a follow-app reminder becomes stale, remove the pending request and add a rebuilt request under the same identifier. Preserve the canonical schedule while doing so.

Repeating requests need special care. Repetition repeats the scheduled content as well as the trigger. If the language changes, replace the repeating request rather than waiting for the next app release. If replacement fails, keep the semantic record marked as needing reconciliation instead of pretending the request is current.

Render when an Android alarm fires

On Android, an alarm receiver or worker can load the semantic record and render text near delivery time. This reduces rescheduling after an app-language change because the receiver reads the current effective locale before posting the notification.

fun deliverReminder(context: Context, reminderId: String) {
    val reminder = reminderStore.require(reminderId)
    val locale = localeStore.effectiveLocale()
    val localizedContext = context.withLocale(locale)

    val notification = NotificationCompat.Builder(
        localizedContext,
        REMINDER_CHANNEL_ID
    )
        .setSmallIcon(R.drawable.ic_reminder)
        .setContentTitle(localizedContext.getString(R.string.reminder_title))
        .setContentText(messageFormatter.format(localizedContext, reminder))
        .setContentIntent(openReminderIntent(context, reminder))
        .setAutoCancel(true)
        .build()

    notificationManager.notify(reminder.stableNotificationId, notification)
}

This is an implementation pattern, not a promise that every background path will run at an exact instant. Android's alarm guidance distinguishes inexact, exact, and user-visible alarm cases. Choose the least demanding mechanism that still meets the product requirement. A medication alarm and a casual re-engagement reminder do not have the same timing contract.

If your Android framework pre-renders text and stores it in a scheduled plugin request, treat it like the iOS path. The real distinction is render timing, not the platform logo.

Reconcile reminders after language changes

A locale switch needs one transaction that updates app state and notification state. Run it after the new locale is persisted and translation resources are available.

  1. Save the canonical effective locale.
  2. Query semantic reminders that have not fired or still recur.
  3. Select records whose policy is follow-app and whose renderedLocale or translationVersion is stale.
  4. Rebuild pre-rendered platform requests without changing their reminder IDs or schedule semantics.
  5. Update each record only after the platform accepts the replacement.
  6. Leave failed records in a retryable reconciliation state and expose a diagnostic count.

Avoid deleting every pending request. Other product features or SDKs may own notifications in the same app. Use a namespace or a stored request identifier so reconciliation touches only records owned by this reminder subsystem.

A translation release can run the same reconciliation without a locale change. Increment a translation bundle version when notification copy changes, then rebuild only reminders whose message keys are affected. This prevents a typo fix from waiting until users edit or recreate every reminder.

Keep Android channel identity stable

Android notification channels have their own lifecycle. The platform guide to creating and managing notification channels requires a channel for notifications on Android 8.0 and later. Users can control channel behavior in system settings, so channel IDs are durable product identifiers.

Keep a stable channel ID such as reminders. Localize the visible channel name and description, and call channel creation again when the effective app language changes. Do not create IDs such as reminders-en and reminders-fr. Locale-specific IDs split user settings and leave obsolete channels behind.

Changing a channel label does not replace notification content. Reconcile channel metadata and pending reminder text as separate operations. Also test the system settings screen because it is where users will see the channel name.

Route taps without preserving stale locale

A local notification should carry a stable reminder ID and destination, not a rendered-language assumption. Android's guide to creating a notification covers attaching a pending intent for the tap action. On iOS, the notification's user information can carry the same semantic identifier.

When the user taps a follow-app reminder, resolve the current effective locale and open the destination in that language. Do not force the locale captured when the notification was scheduled. For a snapshot reminder, pass the snapshot locale as explicit content context while leaving global app-language state alone unless the product requires otherwise.

Make tap handling idempotent. The reminder may have been deleted, completed on another device, or migrated since scheduling. Open a safe reminders list with a clear message if the target no longer exists. Never parse the displayed notification body to recover an entity ID.

Handle failures without losing the reminder

Rescheduling has a dangerous failure mode: removing a valid old request before the replacement is accepted. Build and validate the replacement first. Where platform APIs require removal before addition, keep the canonical reminder and mark reconciliation as incomplete so the app can retry at launch or the next safe background opportunity.

Translation failures need a defined fallback. If a key or required argument is missing, do not schedule a raw key. Use the same reviewed locale fallback chain as the app, record the fallback locale in renderedLocale, and emit a diagnostic event with the message key and translation version. Exclude user-entered reminder text from translation unless the feature explicitly promises machine translation.

Time changes are separate from language changes. A reminder defined as 9:00 local wall time may need rescheduling after a time-zone change; a fixed instant should not. Keep the timing policy in the semantic record so locale reconciliation does not accidentally shift delivery.

Permission denial should not delete the canonical reminder. The user may later enable notifications. Keep the reminder visible in the app, show its notification state, and reconcile it after permission becomes available.

Verify the full lifecycle on devices

Unit tests should verify message-key lookup, plural arguments, locale fallback, translation-version comparison, and stable identifier generation. Integration tests should exercise the operating-system boundary.

Use at least this device sequence for every supported scheduling implementation:

  1. Set the app to English and schedule one single reminder plus one repeating reminder.
  2. Confirm both pending records use the expected identifiers, fire times, and English text.
  3. Change the app to French without restarting it.
  4. Confirm the semantic records remain unchanged except for reconciliation metadata.
  5. Inspect pending pre-rendered requests and verify French title and body text.
  6. Suspend or terminate the app, then let each reminder fire.
  7. Tap each notification and verify the current French destination opens.
  8. Change only the translation version, rebuild affected requests, and confirm timing did not move.
  9. On Android, inspect the localized channel label and confirm user-selected channel behavior remains intact.
  10. Disable notification permission, schedule a reminder, restore permission, and verify reconciliation recovers it without duplication.

Add fixtures for plural counts, long translations, right-to-left text, non-Latin scripts, missing keys, and daylight-saving boundaries. Check the lock screen, notification center, wearable mirror if supported, and the destination screen. A passing in-app preview does not prove that a suspended delivery path uses the same locale.

Start by inventorying every code path that schedules a local notification. For each one, write down its semantic record, render point, locale policy, request identifier, translation version, and reconciliation trigger. Any path that cannot answer those six questions is the next one to fix.

For server-sent messages, see how to localize push notifications.

References