Back to Blog

Live Activities in Foodtech: How One Feature Solves Three Business Problems

Eugene Setsko - dev.family
Eugene Setsko
mobile developer

Dec 19, 2025

16 minutes reading

Live Activities in Foodtech: How One Feature Solves Three Business Problems - dev.family

If you have launched a delivery app in the last two years, you have most likely faced the same questions: “Why don’t users return after their first order?”, “How can we reduce the load on customer support?”, and “How are we actually different from our competitors?”

Live Activities - an Apple technology introduced in iOS 16.1 - unexpectedly turned out to be an answer to all three questions at once. But let’s skip the hype and look at the facts: when it really works, and when it’s just a nice-looking toy.

What It Is and Why Now

Live Activities is a widget on the lock screen that updates in real time.The user places an order → the widget appears → the status updates without opening the app.

It sounds simple, but the effect is non-trivial.

According to CleverTap, 90% of users delete an app within 30 days after installation. At the same time, users look at the lock screen dozens of times a day. Live Activities addresses the core problem of any mobile product — the fight for attention.

Three Problems Live Activities Solve

1. Reducing Support Load by 20–40%

The problem

A classic scenario - a significant share of support requests starts with “Where is my order?”. According to ShippyPro research, “Where is my order?” (WISMO) requests represent one of the largest ticket categories in the delivery business. The user places an order, receives a push notification saying “We’ve received your order”, and then - silence. After 20 minutes, anxiety kicks in and calls begin.

What Live Activities provide

  • Order status always visible: “Preparing” → “Courier on the way” → “5 minutes to delivery”
  • Progress bar to visualize stages
  • Automatic updates via push notifications without opening the app

Cost-saving potential

Studies show that improving UX can reduce related support tickets by 25–40%. For example, when PhotoSì automated delivery tracking and notifications, the company achieved a 20% reduction in support requests and a 30% reduction in operational time.

If a single ticket costs $2–5 to process and you have 500+ tickets per day, savings can reach $10,000–30,000 per month.For companies with multi-million-dollar revenues this may not be critical. But for a startup with a limited budget and a support team of 3–5 people working at full capacity, these percentages free up time for truly complex cases.

2. Retention Growth of 23.7%

The problem

The user installs the app, places an order, receives food — and forgets about you. Next time they’re hungry, they open a competitor’s app simply because its icon appeared first.

What Live Activities provide

Constant visual presence builds brand recall. According to OneSignal and GoodRequest, apps using Live Activities show a 23.7% higher 30-day retention rate. This is not theory — these are real metrics from the State of Customer Engagement Report.

The psychology is simple:

  • The user sees “Order delivered ✓” on the lock screen
  • A successful experience gets reinforced
  • At the next hunger moment, your brand comes to mind

Industry context

According to AppsFlyer, average 30-day retention for food delivery apps is only 3–4%. This means that out of 100 new users, only 3–4 return after a month. Even a small increase in retention is critically important.

At the same time, 62% of Live Activities users rate their experience as “good” or “excellent”, which directly affects their willingness to return.

3. Differentiation in an Oversaturated Market

The problem

All delivery apps look the same. Food cards, cart, payment, courier tracking on a map. Users don’t see the difference between you and dozens of competitors.

Market reality

What Live Activities provide

A premium UX feature that not everyone has. As of the end of 2024, most local delivery services in the CIS have not implemented it yet. This gives you a first-mover advantage.

Effect on NPS

Average NPS in fast food is around 30 points, while restaurants with good service score 53+. An NPS above 60 is considered excellent.

When companies introduce noticeable UX improvements (and Live Activities is exactly that kind of feature), NPS grows. For food delivery apps, NPS typically ranges from -4 to 40 depending on service quality.

When Live Activities Are Actually Needed

You should consider them

You can skip

✅ You have more than 500 orders per day. With fewer orders, ROI is questionable. Development + integration + testing = 80–120 team hours. With low volume, the effect won’t justify the investment.

❌ You are an early-stage startup with an MVP and ~50 orders per week

✅ Average order fulfillment time is more than 15 minutes. If preparation takes 5 minutes, Live Activity won’t have time to matter. Average delivery time is around 40 minutes, which is ideal for Live Activities.

❌ You don’t have a dedicated developer and are not ready to involve a team

✅ You already have an iOS app Obvious, but important: Live Activities work only on iOS 16.1+. If you don’t have an app yet, contact us - we build cross-platform apps and save development time.

❌ Your backend does not support real-time events

✅ iOS users make up more than 40% of your audience Although Android dominates globally, in developed markets (US, Europe) iOS holds 40–60% of the food delivery market. iOS users also have a higher average revenue per user - $532.60 vs less on Android.

❌ Your budget is limited and there are more critical priorities

✅ You have a backend capable of real-time updates

Startup alternative

Focus on basic push notifications with rich content. Push notifications increase retention by an average of 5x and are cheaper to implement.

MaxB - dev.family

If you still have questions, book a free consultation

Max B. CEO

ROI: An Honest Conversation About Money

Your Investment

Development:

  • 100 team hours × $50–80/hour = $5,000–8,000
  • Or 2–3 weeks of work by one middle/senior iOS or cross-platform developer

Infrastructure:

  • APNs - free
  • Push token storage - negligible
  • Additional backend load - minimal

Return

Example calculation (15,000 orders/day, 50% iOS):

Support cost savings:

  • 20–30% fewer tickets = $5,000–10,000/month
  • $60,000–120,000 per year

Retention growth (+23.7%): Harder to calculate directly, but assuming each additional returning user makes at least one order per month:

  • Average order $20, margin 30% = $6 profit
  • 15,000 orders × 50% iOS × 23.7% retention lift × 15% repeat conversion ≈ 265 extra orders/day
  • 265 × $6 × 30 days ≈ $48,000/month additional profit

Payback timeline: Positive ROI within 1–2 months after launch.

Formula: ROI = (Support savings + Retention growth) / Development cost

Where:

  • Support savings = Saved tickets × Cost per ticket
  • Retention growth = Additional orders × Average order value × Margin

Technical Side: What a CTO Needs to Know

Backend Requirements

To support Live Activities, you need:

  1. Push infrastructure: APNs (Apple Push Notification service).
  2. Token storage: Live Activity generates its own push token (separate from regular notifications). It must be received on the client → sent to backend → stored → used for updates.

iOS Requirements

  • iOS 16.1+ (Live Activities introduction)
  • iOS 16.2+ (push updates via APNs)
  • Widget Extension in the project

Pitfalls

  1. Keychain instead of User. Defaults for secure token storageShared across processes - allows data sharing between widget and app
  2. Automatic auth token refresh. If your access token lives 15 minutes but delivery takes 40 minutes, the widget will lose authorization
  3. Design limitations. Apple strictly limits Live Activity UI. No “Cancel order” buttons - only information and a single tap to open the app
  4. Battery usage. Frequent updates drain battery. Optimal frequency: every 30–60 seconds

Implementation Timeline

  • Basic integration: 40–60 hours
  • Full implementation: 80–120 hours
  • QA + fixes: +20–30 hours

What’s Next

For Those Ready to Implement

  1. Audit current infrastructure. Check real-time updates, APNs setup, iOS 16.1+ adoption
  2. Build a prototype (1–2 weeks). Don’t ship straight to production
  3. Roll out to 10% of users. Gradual rollout helps catch bugs
  4. Analyze metrics after one month. Compare retention, NPS, support volume

For Those Unsure

Start simple:

  • Improve current push notifications
  • Add rich notifications
  • Optimize frequency and timing

Once these basics work, Live Activities become a logical next step.

Or contact us — we’ll help you figure it out

Important: Live Activities in Cross-Platform Apps

A common CTO question: “We use React Native / Flutter / another cross-platform framework. Do we need to rewrite everything in Swift?”

Short answer: No.

Cross-Platform Reality

If your app is built with React Native, Flutter, or another cross-platform solution, implementing Live Activities does not require rewriting the entire app. You only need a native module - a bridge between your JS/Dart code and the Swift widget implementation.

How it works:

  1. Native Swift module. All Live Activity logic is implemented in Swift/SwiftUI (Apple requirement). The Widget Extension is a separate Xcode target.
  2. Bridge to cross-platform code. A thin bridge layer allows calling native methods from JS/Dart.
  3. Minimal integration. From JS you only call something like startLiveActivity(orderData) - the widget appears. Everything else runs natively.

Practical Implications

Pros:

  • Core codebase remains untouched
  • Live Activity is isolated
  • Can be added gradually
  • Updates happen via push notifications

Typical React Native Architecture

├── React Native App (JavaScript)
│   └── NativeModules.LiveActivityModule.start(orderData)
│
├── Native Bridge (Objective-C)
│
└── Live Activity Widget (Swift/SwiftUI)

In our case:

We implemented Live Activities for a client’s React Native app. The main app stayed unchanged; the widget was written in pure Swift. Integration took ~100 hours and required no changes to the existing codebase.

The same principle applies to Flutter, Xamarin, Ionic.

Technical Implementation of Live Activities in React Native

Overall Solution Concept

All Live Activity functionality was implemented in Swift/SwiftUI and integrated into React Native via a native module.

Only a single method to start the Live Activity was exposed to the JS layer. This method is called after a successful order creation.

Native modules documentation (legacy architecture): https://reactnative.dev/docs/legacy/native-modules-ios

Project Structure (Widget Target)

Below is the actual structure of the OrderTracker directory, which contains all Live Activity logic:

OrderTracker
│
├── Helpers
├── Localization
├── Models
├── Presentation
├── Resources
├── Services
│
├── Info.plist
│
├── OrderTrackerAttributes.swift
├── OrderTrackerBundle.swift
├── OrderTrackerLiveActivity.swift
│
├── OrderTrackerModule.swift
├── OrderTrackerModuleBridge.m
└── PushTokenEmitter.swift

Creating a Native Swift Module for React Native

React Native natively supports native modules written in Objective-C. To connect a module written in Swift, you need to:

  1. Create a Swift file (OrderTrackerModule.swift) Xcode will automatically suggest creating a Bridging Header — accept it.
  2. Import RCTBridgeModule in the bridging header
// sizlapp-Bridging-Header.h
#import <React/RCTBridgeModule.h>

This header allows Swift classes to be accessible from the Objective-C layer, which already interacts with React Native.

Native Module Implementation

Below is the main class that:

  • starts a Live Activity (startLiveActivity)
  • tracks the push token (observePushTokenUpdates)
  • provides access to the active Activity
//OrderTrackerModule.swift
import Foundation
import ActivityKit
import React
@objc(OrderTrackerModule)
class OrderTrackerModule: NSObject {
  static func areActivitiesEnabled() -> Bool {
      return ActivityAuthorizationInfo().areActivitiesEnabled
  }
  static func getLiveActivity(for orderId: Int) -> Activity<OrderTrackerAttributes>? {
    return Activity<OrderTrackerAttributes>.activities
      .first(where: { (activity: Activity<OrderTrackerAttributes>) in
        if #available(iOS 16.2, *) {
          return activity.content.state.orderId == orderId
        } else {
          return false
        }
      })
  }
  static func observePushTokenUpdates(for orderId: Int) {
    guard let liveActivity = getLiveActivity(for: orderId) else {
        return
    }
    Task{
       for await pushToken in liveActivity.pushTokenUpdates {
         let pushTokenString = pushToken.reduce("") {
           $0 + String(format: "%02x", $1)
         }
         AppContainer.shared.pushTokenUploader.upload(push: pushTokenString, for: orderId) { result in
           switch result {
             case .success:
             print("Push token uploaded successfully")
             break
           case .failure(let error):
             print("Error uploading push token: \(error)")
           }
         }
       }
     }
  }
  @objc
  func startLiveActivity(_ recordId: NSNumber, _ fulfillmentType: String, _ scheduleTime: Dictionary<String, Any>?, _ eta: Dictionary<String, Any>?, _ estimateDropOffAt: String?, _ status: String, _ deliveryProgress: NSNumber, _ orderId: NSNumber, _ isKitchenAlerted: NSNumber, _ isContactlessDelivery: NSNumber, _ orderNum: NSNumber) -> Void {
    if !OrderTrackerModule.areActivitiesEnabled() {
      return
    }
    let scheduleTime = ScheduleTimeInterval.convert(from: scheduleTime)
    let eta = ScheduleTimeInterval.convert(from: eta)
    let orderStatus = OrderStatus(rawValue: status) ?? .Unknown
    let orderType = OrderFulfillmentType(rawValue: fulfillmentType) ?? .Unknown
    let contentState = OrderTrackerAttributes.ContentState(
      fulfillmentType: orderType,
      status: orderStatus,
      scheduleTime: scheduleTime,
      eta: eta,
      deliveryProgress: deliveryProgress.intValue,
      estimateDropOffAt: estimateDropOffAt,
      orderId: orderId.intValue,
      isKitchenAlerted: isKitchenAlerted.boolValue,
      isContactlessDelivery: isContactlessDelivery.boolValue,
      orderNum: orderNum.intValue
    );
    do {
      if #available(iOS 16.2, *) {
        let activityContent = ActivityContent<OrderTrackerAttributes.ContentState>(state: contentState, staleDate: nil)
        let activityAttributes = OrderTrackerAttributes(recordId: recordId.intValue);
        let _ = try Activity.request(attributes: activityAttributes, content: activityContent, pushType: .token)
        OrderTrackerModule.observePushTokenUpdates(for: orderId.intValue)
      }
        } catch (let error) {
          print("Error requesting Live Activity \(error).")
        }
  }
}

Exporting the Module to React Native (Objective-C Layer)

React Native calls only the Objective-C API, so a bridge file is created:

// OrderTrackerModuleBridge.m
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(OrderTrackerModule, NSObject)
RCT_EXTERN_METHOD(startLiveActivity:(nonnull NSNumber *)recordId
                  :(nonnull NSString *)fulfillmentType
                  :(nullable NSDictionary *)scheduleTime
                  :(nullable NSDictionary *)eta
                  :(nullable NSString *)estimateDropOffAt
                  :(nonnull NSString *)status
                  :(nonnull NSNumber *)deliveryProgress
                  :(nonnull NSNumber *)orderId
                  :(nonnull NSNumber *)isKitchenAlerted
                  :(nonnull NSNumber *)isContactlessDelivery
                  :(nonnull NSNumber *)orderNum)
RCT_EXTERN_METHOD(stopLiveActivity:(nonnull NSNumber *)recordId)
@end

After that, the module automatically becomes available in JS.

Calling Live Activity from JS

import { NativeModules } from "react-native";
NativeModules.OrderTrackerModule.startLiveActivity(
  orderId,
  response.order.pickupInfo ? "Pickup" : "Delivery",
  { from: response.order?.etaIntervalStart, to: response.order?.etaIntervalEnd },
  { from: response.order?.etaIntervalStart, to: response.order?.etaIntervalEnd },
  response.order.dropoffArrivalTime,
  orderStatus.toLiveActivityString(response.order.statusExtended),
  0,
  orderId,
  0,
  response.order.address.contactlessDelivery ? 1 : 0,
  +response.order.number,
);

Getting the Push Token for a Live Activity

A Live Activity can be updated via push notifications.

For the server to be able to update the order status, you need to obtain the Live Activity push token and send it to the server. Since the JS layer may not be active when the app is closed, token submission is implemented entirely in the native part.

Network Layer in the Widget

All API work is moved into NetworkClient.swift, AuthorizedNetworkClient.swift, and AuthSessionRenewer.swift.

Capabilities:

  • adding an authorization token
  • automatic accessToken renewal using refreshToken
  • standard error handling

Example logic with automatic token renewal:

func request(path: String, httpMethod: HTTPMethod, httpBody: Data?, headers: [String: String]?, handler: @escaping (Result<Data?, NetworkError>) -> Void) {
    let authToken = try? getAuthToken()
    var HeadersWithAuth = headers ?? [:]
    if let authToken = authToken {
      HeadersWithAuth["Authorization"] = "Bearer \(authToken)"
    }
    api.request(path: path, httpMethod: httpMethod,httpBody: httpBody, headers: HeadersWithAuth) { result in
      switch result {
      case .success(let data):
        handler(.success(data))
      case .failure(let error):
        switch error {
        case .codeError(let statusCode):
          if statusCode == 401 {
            self.sessionRenewer.renew { result in
              switch result {
              case .success:
                self.request(path: path, httpMethod: httpMethod, httpBody: httpBody, headers: headers, handler: handler)
              case .failure:
                handler(.failure(error))
              }
            }
          } else {
            handler(.failure(error))
          }
        default:
          handler(.failure(error))
        }
      }
      
    }
  }

Token Storage (Keychain)

Since both the widget and the app must have access to the tokens, Keychain is used to store them. The reason is that the app, widgets, Live Activity, and other extensions are different independent processes that iOS launches at different times and in different sandbox environments. They do not share UserDefaults, a shared file system, or any shared storage by default. These restrictions are introduced for security reasons: iOS isolates each process so that one process cannot access another process’s data, even if it belongs to the same developer.

The JS layer writes tokens via the native module → the widget uses them for authorization.

Example implementation of the Storage class:

import Foundation
import Security
final class Storage: StorageProtocol {
  let authTokenKey = "KEY_ACCESS"
  let refreshTokenKey = "KEY_REFRESH"
  
  private enum KeychainError: Error {
    case unhandledError(status: OSStatus)
    case itemNotFound
    case encodingError
  }
  
  func save<T: Codable>(key: String, value: T) throws {
    let data = try? JSONEncoder().encode(value)
    guard let data else { throw KeychainError.encodingError }
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: data
    ]
    SecItemDelete(query as CFDictionary)
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
  }
  
  func load(key: String) throws -> Data? {
    let query: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrAccount as String: key,
      kSecMatchLimit as String: kSecMatchLimitOne,
      kSecReturnData as String: true
    ]
    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    guard status != errSecItemNotFound else { throw KeychainError.itemNotFound }
    guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
    return item as? Data
  }
}

OrderTrackerAttributes - the basis for push updates

The server can update only the fields that are declared inside ContentState.

struct OrderTrackerAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
      var fulfillmentType: OrderFulfillmentType
      var status: OrderStatus
      var scheduleTime: ScheduleTimeInterval?
      var eta: ScheduleTimeInterval?
      var deliveryProgress: Int?
      var estimateDropOffAt: String?
      var orderId: Int
      var isKitchenAlerted: Bool?
      var isContactlessDelivery: Bool?
      var orderNum: Int
    }
    var recordId: Int
}

Visual Layer (SwiftUI)

The project UI is located in the Presentation folder — this is the layer responsible exclusively for rendering. It contains SwiftUI views, Live Activity components, visual cards, progress indicators, and everything related to how the user sees the data.

At the same time, all business logic is deliberately moved out of the UI and encapsulated in view models located in the Models folder. The view models are responsible for deciding which data to display, in what format, how to transform order statuses, how to calculate remaining time, and when to update the activity or show a specific UI scenario. The Presentation layer remains as thin and declarative as possible, while all behavior and calculation logic is centralized in the models — making the architecture clean, predictable, and highly scalable.

Example

//StatusView.swift
struct StatusView: View {
  let viewModel: StatusViewModel
  var body: some View {
    Text(LocalizedStringKey(viewModel.statusText))
  }
}

//StatusViewModel.swift

import Foundation
struct StatusViewModel: TimeLeftProtocol {
  let status: OrderStatus
  let orderType: OrderFulfillmentType
  let estimateDropOffAt: String?
  let eta: ScheduleTimeInterval?
  
  private var isPickup: Bool {
    orderType == .Pickup
  }
  
  private var statusUiString: String {
    isPickup ? status.toPickupUiString() : status.toDeliveryUiString()
  }
  
  private var formattedTimeLeft: String {
    if let timeLeft = timeLeft {
      return String(format: NSLocalizedString("min_away", comment: ""), String(timeLeft))
    }
    return ""
  }
  
  var statusText: String {
    if status == .Cancelled {
      return "order_cancelled"
    }
    guard let timeLeft else {
      return statusUiString
    }
    if(timeLeft <= orderNearCompletionThresholdMinutes) {
      return isPickup ? "almost_ready" : "almost_there"
    } else {
      return isPickup ? statusUiString : formattedTimeLeft
    }
  }
}

How it will look as a result

Visual Layer (SwiftUI)

Summary

As a result of the work completed, a full Live Activity module for iOS was developed. The widget architecture was implemented with clear layer separation. Native Swift modules with a bridge to React Native were set up. Live Activity push tokens were handled. Authorization, token renewal, and the network client were implemented. The UI was built using SwiftUI. All key elements were separated into dedicated layers (Models, Presentation, Services).

Final Note

All numbers in this article are based on public industry research and reports. Actual results may vary depending on:

  • Business model (aggregator vs own network)
  • Geography (US vs Europe vs CIS)
  • Product maturity
  • Quality of implementation

We recommend running your own A/B tests to measure the effect for your specific case.

You may also like: