diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js
--- a/keyserver/src/push/send.js
+++ b/keyserver/src/push/send.js
@@ -25,7 +25,10 @@
   rawThreadInfoFromServerThreadInfo,
   threadInfoFromRawThreadInfo,
 } from 'lib/shared/thread-utils.js';
-import { hasMinCodeVersion } from 'lib/shared/version-utils.js';
+import {
+  hasMinCodeVersion,
+  NEXT_CODE_VERSION,
+} from 'lib/shared/version-utils.js';
 import type { Platform, PlatformDetails } from 'lib/types/device-types.js';
 import { messageTypes } from 'lib/types/message-types-enum.js';
 import {
@@ -69,6 +72,7 @@
   wnsMaxNotificationPayloadByteSize,
   wnsPush,
   type WNSPushError,
+  blobServiceUpload,
 } from './utils.js';
 import createIDs from '../creators/id-creator.js';
 import { createUpdates } from '../creators/update-creator.js';
@@ -1022,6 +1026,23 @@
     }));
   }
 
+  // The `messageInfos` field in notification payload is
+  // not used on MacOS so we can return early.
+  if (platformDetails.platform === 'macos') {
+    const macOSNotifsWithoutMessageInfos =
+      await prepareEncryptedAPNsNotifications(
+        devices,
+        notification,
+        platformDetails.codeVersion,
+      );
+    return macOSNotifsWithoutMessageInfos.map(
+      ({ notification: notif, deviceToken }) => ({
+        notification: notif,
+        deviceToken,
+      }),
+    );
+  }
+
   const notifsWithMessageInfos = await prepareEncryptedAPNsNotifications(
     devices,
     copyWithMessageInfos,
@@ -1049,6 +1070,32 @@
     );
   }
 
+  const canQueryBlobService = hasMinCodeVersion(platformDetails, {
+    native: NEXT_CODE_VERSION,
+  });
+
+  let blobHash, encryptionKey, blobUploadError;
+  if (canQueryBlobService) {
+    ({ blobHash, encryptionKey, blobUploadError } = await blobServiceUpload(
+      copyWithMessageInfos.compile(),
+    ));
+  }
+
+  if (blobUploadError) {
+    console.warn(
+      `Failed to upload payload of notification: ${uniqueID} ` +
+        `due to error: ${blobUploadError}`,
+    );
+  }
+
+  if (blobHash && encryptionKey) {
+    notification.payload = {
+      ...notification.payload,
+      blobHash,
+      encryptionKey,
+    };
+  }
+
   const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications(
     devicesWithExcessiveSize,
     notification,
diff --git a/native/ios/Comm.xcodeproj/project.pbxproj b/native/ios/Comm.xcodeproj/project.pbxproj
--- a/native/ios/Comm.xcodeproj/project.pbxproj
+++ b/native/ios/Comm.xcodeproj/project.pbxproj
@@ -92,6 +92,8 @@
 		CBB0DF612B768007008E22FF /* CommMMKV.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBB0DF5F2B768007008E22FF /* CommMMKV.mm */; };
 		CBCA09062A8E0E7400F75B3E /* StaffUtils.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBCA09052A8E0E6B00F75B3E /* StaffUtils.cpp */; };
 		CBCA09072A8E0E7D00F75B3E /* StaffUtils.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBCA09052A8E0E6B00F75B3E /* StaffUtils.cpp */; };
+		CBCF984F2BA499DA00DBC3D9 /* CommIOSBlobClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */; };
+		CBCF98502BA49A0500DBC3D9 /* CommIOSBlobClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */; };
 		CBDEC69B28ED867000C17588 /* GlobalDBSingleton.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBDEC69A28ED867000C17588 /* GlobalDBSingleton.mm */; };
 		CBFBEEBA2B4ED90600729F1D /* RustBackupExecutor.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBFBEEB82B4ED90600729F1D /* RustBackupExecutor.cpp */; };
 		CBFE58292885852B003B94C9 /* ThreadOperations.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBFE58282885852B003B94C9 /* ThreadOperations.cpp */; };
@@ -315,6 +317,8 @@
 		CBCA09042A8E0E6B00F75B3E /* StaffUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StaffUtils.h; sourceTree = "<group>"; };
 		CBCA09052A8E0E6B00F75B3E /* StaffUtils.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = StaffUtils.cpp; sourceTree = "<group>"; };
 		CBCF57AB2B05096F00EC4BC0 /* AESCryptoModuleObjCCompat.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AESCryptoModuleObjCCompat.h; path = Comm/CommAESCryptoUtils/AESCryptoModuleObjCCompat.h; sourceTree = "<group>"; };
+		CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = CommIOSBlobClient.mm; path = Comm/CommIOSServices/CommIOSBlobClient.mm; sourceTree = "<group>"; };
+		CBCF984E2BA499DA00DBC3D9 /* CommIOSBlobClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CommIOSBlobClient.h; path = Comm/CommIOSServices/CommIOSBlobClient.h; sourceTree = "<group>"; };
 		CBDEC69928ED859600C17588 /* GlobalDBSingleton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GlobalDBSingleton.h; sourceTree = "<group>"; };
 		CBDEC69A28ED867000C17588 /* GlobalDBSingleton.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = GlobalDBSingleton.mm; path = Comm/GlobalDBSingleton.mm; sourceTree = "<group>"; };
 		CBF9DAE22B595934000EE771 /* EntityQueryHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EntityQueryHelpers.h; sourceTree = "<group>"; };
@@ -413,6 +417,7 @@
 		71B8CCB626BD30EC0040C0A2 /* CommCoreImplementations */ = {
 			isa = PBXGroup;
 			children = (
+				CBCF984C2BA499C200DBC3D9 /* CommIOSServices */,
 				CBB0DF5F2B768007008E22FF /* CommMMKV.mm */,
 				CB74AB1B2B2AFF6E00CBB494 /* CommServicesAuthMetadataEmitter.mm */,
 				DFD5E77D2B05264000C32B6A /* AESCrypto.mm */,
@@ -783,6 +788,15 @@
 			name = CommAESCryptoUtils;
 			sourceTree = "<group>";
 		};
+		CBCF984C2BA499C200DBC3D9 /* CommIOSServices */ = {
+			isa = PBXGroup;
+			children = (
+				CBCF984E2BA499DA00DBC3D9 /* CommIOSBlobClient.h */,
+				CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */,
+			);
+			name = CommIOSServices;
+			sourceTree = "<group>";
+		};
 		CBED0E2C284E086100CD3863 /* PersistentStorageUtilities */ = {
 			isa = PBXGroup;
 			children = (
@@ -1158,6 +1172,7 @@
 				8EF775682A74032C0046A385 /* CommRustModule.cpp in Sources */,
 				34055C152BAD31AC0008E713 /* SyncedMetadataStore.cpp in Sources */,
 				8E43C32C291E5B4A009378F5 /* TerminateApp.mm in Sources */,
+				CBCF984F2BA499DA00DBC3D9 /* CommIOSBlobClient.mm in Sources */,
 				B3B02EBF2B8538980020D118 /* CommunityStore.cpp in Sources */,
 				8BC9568529FC49B00060AE4A /* JSIRust.cpp in Sources */,
 				8EA59BD92A73DAB000EB4F53 /* rustJSI-generated.cpp in Sources */,
@@ -1217,6 +1232,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				CBCF98502BA49A0500DBC3D9 /* CommIOSBlobClient.mm in Sources */,
 				CBCA09072A8E0E7D00F75B3E /* StaffUtils.cpp in Sources */,
 				CB3C0A3B2A125C8F009BD4DA /* NotificationsCryptoModule.cpp in Sources */,
 				CB90951F29534B32002F2A7F /* CommSecureStore.mm in Sources */,
diff --git a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h
new file mode 100644
--- /dev/null
+++ b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h
@@ -0,0 +1,6 @@
+#import <Foundation/Foundation.h>
+
+@interface CommIOSBlobClient : NSObject
++ (id)sharedInstance;
+- (NSData *)getBlobSync:(NSString *)blobHash orSetError:(NSError **)error;
+@end
diff --git a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm
new file mode 100644
--- /dev/null
+++ b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm
@@ -0,0 +1,168 @@
+#import "CommIOSBlobClient.h"
+#import "CommSecureStore.h"
+#import "Logger.h"
+
+#ifdef DEBUG
+NSString const *blobServiceAddress =
+    @"https://blob.staging.commtechnologies.org";
+#else
+NSString const *blobServiceAddress = @"https://blob.commtechnologies.org";
+#endif
+
+int const blobServiceQueryTimeLimit = 15;
+
+@interface CommIOSBlobClient ()
+@property(nonatomic, strong) NSURLSession *sharedBlobServiceSession;
+@end
+
+@implementation CommIOSBlobClient
+
++ (id)sharedInstance {
+  static CommIOSBlobClient *sharedBlobServiceClient = nil;
+  static dispatch_once_t onceToken;
+
+  dispatch_once(&onceToken, ^{
+    NSURLSessionConfiguration *config =
+        [NSURLSessionConfiguration ephemeralSessionConfiguration];
+
+    [config setTimeoutIntervalForRequest:blobServiceQueryTimeLimit];
+    NSURLSession *session =
+        [NSURLSession sessionWithConfiguration:config
+                                      delegate:nil
+                                 delegateQueue:[NSOperationQueue mainQueue]];
+    sharedBlobServiceClient = [[self alloc] init];
+    sharedBlobServiceClient.sharedBlobServiceSession = session;
+  });
+  return sharedBlobServiceClient;
+}
+
+- (NSData *)getBlobSync:(NSString *)blobHash orSetError:(NSError **)error {
+  NSError *authTokenError = nil;
+  NSString *authToken =
+      [CommIOSBlobClient _getAuthTokenOrSetError:&authTokenError];
+
+  if (authTokenError) {
+    *error = authTokenError;
+    return nil;
+  }
+
+  NSString *blobUrlStr = [blobServiceAddress
+      stringByAppendingString:[@"/blob/" stringByAppendingString:blobHash]];
+  NSURL *blobUrl = [NSURL URLWithString:blobUrlStr];
+  NSMutableURLRequest *blobRequest =
+      [NSMutableURLRequest requestWithURL:blobUrl];
+
+  // This is slightly against Apple docs:
+  // https://developer.apple.com/documentation/foundation/nsurlrequest?language=objc#1776617
+  // but apparently there is no other way to
+  // do this and even Apple staff members
+  // advice to set this field manually to
+  // achieve token based authentication:
+  // https://developer.apple.com/forums/thread/89811
+  [blobRequest setValue:authToken forHTTPHeaderField:@"Authorization"];
+
+  __block NSError *requestError = nil;
+  __block NSData *blobContent = nil;
+
+  dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
+  NSURLSessionDataTask *task = [self.sharedBlobServiceSession
+      dataTaskWithRequest:blobRequest
+        completionHandler:^(
+            NSData *_Nullable data,
+            NSURLResponse *_Nullable response,
+            NSError *_Nullable error) {
+          @try {
+            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
+            if (httpResponse.statusCode > 299) {
+              NSString *errorMessage =
+                  [@"Fetching blob failed with the following reason: "
+                      stringByAppendingString:[NSHTTPURLResponse
+                                                  localizedStringForStatusCode:
+                                                      httpResponse.statusCode]];
+              requestError = [NSError
+                  errorWithDomain:@"app.comm"
+                             code:httpResponse.statusCode
+                         userInfo:@{NSLocalizedDescriptionKey : errorMessage}];
+              return;
+            }
+            if (error) {
+              requestError = error;
+              return;
+            }
+            blobContent = data;
+          } @catch (NSException *exception) {
+            comm::Logger::log(
+                "Received exception when fetching blob. Details: " +
+                std::string([exception.reason UTF8String]));
+          } @finally {
+            dispatch_semaphore_signal(semaphore);
+          }
+        }];
+
+  [task resume];
+  dispatch_semaphore_wait(
+      semaphore,
+      dispatch_time(
+          DISPATCH_TIME_NOW,
+          (int64_t)(blobServiceQueryTimeLimit * NSEC_PER_SEC)));
+  if (requestError) {
+    *error = requestError;
+    return nil;
+  }
+  return blobContent;
+}
+
++ (NSString *)_getAuthTokenOrSetError:(NSError **)error {
+  // Authentication data are retrieved on every request
+  // since they might change while NSE process is running
+  // so we should not rely on caching them in memory.
+
+  auto accessToken = comm::CommSecureStore::get(
+      comm::CommSecureStore::commServicesAccessToken);
+  auto userID = comm::CommSecureStore::get(comm::CommSecureStore::userID);
+  auto deviceID = comm::CommSecureStore::get(comm::CommSecureStore::deviceID);
+
+  NSString *userIDObjC = userID.hasValue()
+      ? [NSString stringWithCString:userID.value().c_str()
+                           encoding:NSUTF8StringEncoding]
+      : @"";
+  NSString *accessTokenObjC = accessToken.hasValue()
+      ? [NSString stringWithCString:accessToken.value().c_str()
+                           encoding:NSUTF8StringEncoding]
+      : @"";
+  NSString *deviceIDObjC = deviceID.hasValue()
+      ? [NSString stringWithCString:deviceID.value().c_str()
+                           encoding:NSUTF8StringEncoding]
+      : @"";
+
+  NSDictionary *jsonAuthObject = @{
+    @"userID" : userIDObjC,
+    @"accessToken" : accessTokenObjC,
+    @"deviceID" : deviceIDObjC,
+  };
+
+  NSData *binaryAuthObject = nil;
+  NSError *jsonError = nil;
+
+  @try {
+    binaryAuthObject = [NSJSONSerialization dataWithJSONObject:jsonAuthObject
+                                                       options:0
+                                                         error:&jsonError];
+  } @catch (NSException *e) {
+    *error = [NSError errorWithDomain:@"app.comm"
+                                 code:NSFormattingError
+                             userInfo:@{NSLocalizedDescriptionKey : e.reason}];
+    return nil;
+  }
+
+  if (jsonError) {
+    *error = jsonError;
+    return nil;
+  }
+
+  return [@"Bearer "
+      stringByAppendingString:[binaryAuthObject
+                                  base64EncodedStringWithOptions:0]];
+}
+
+@end
diff --git a/native/ios/NotificationService/NotificationService.mm b/native/ios/NotificationService/NotificationService.mm
--- a/native/ios/NotificationService/NotificationService.mm
+++ b/native/ios/NotificationService/NotificationService.mm
@@ -1,4 +1,6 @@
 #import "NotificationService.h"
+#import "AESCryptoModuleObjCCompat.h"
+#import "CommIOSBlobClient.h"
 #import "CommMMKV.h"
 #import "Logger.h"
 #import "NotificationsCryptoModule.h"
@@ -14,6 +16,8 @@
 NSString *const encryptionFailureKey = @"encryptionFailure";
 NSString *const collapseIDKey = @"collapseID";
 NSString *const keyserverIDKey = @"keyserverID";
+NSString *const blobHashKey = @"blobHash";
+NSString *const encryptionKeyLabel = @"encryptionKey";
 
 // Those and future MMKV-related constants should match
 // similar constants in CommNotificationsHandler.java
@@ -257,7 +261,32 @@
     publicUserContent = badgeOnlyContent;
   }
 
-  // Step 7: notify main app that there is data
+  // Step 7: (optional) download notification paylaod
+  // from blob service in case it is large notification
+  if ([self isLargeNotification:content.userInfo]) {
+    std::string processLargeNotificationError;
+    try {
+      @try {
+        [self fetchAndPersistLargeNotifPayload:content];
+      } @catch (NSException *e) {
+        processLargeNotificationError =
+            "Obj-C exception: " + std::string([e.name UTF8String]) +
+            " during large notification processing.";
+      }
+    } catch (const std::exception &e) {
+      processLargeNotificationError =
+          "C++ exception: " + std::string(e.what()) +
+          " during large notification processing.";
+    }
+
+    if (processLargeNotificationError.size()) {
+      [errorMessages
+          addObject:[NSString stringWithUTF8String:processLargeNotificationError
+                                                       .c_str()]];
+    }
+  }
+
+  // Step 8: notify main app that there is data
   // to transfer to SQLite and redux.
   [self sendNewMessageInfosNotification];
 
@@ -500,6 +529,32 @@
   content.badge = @(totalUnreadCount);
 }
 
+- (void)fetchAndPersistLargeNotifPayload:
+    (UNMutableNotificationContent *)content {
+  NSString *blobHash = content.userInfo[blobHashKey];
+
+  NSData *encryptionKey = [[NSData alloc]
+      initWithBase64EncodedString:content.userInfo[encryptionKeyLabel]
+                          options:0];
+
+  __block NSError *fetchError = nil;
+  NSData *largePayloadBinary =
+      [CommIOSBlobClient.sharedInstance getBlobSync:blobHash
+                                         orSetError:&fetchError];
+
+  if (fetchError) {
+    comm::Logger::log(
+        "Failed to fetch notif payload from blob service. Details: " +
+        std::string([fetchError.localizedDescription UTF8String]));
+    return;
+  }
+
+  NSDictionary *largePayload =
+      [NotificationService aesDecryptAndParse:largePayloadBinary
+                                      withKey:encryptionKey];
+  [self persistMessagePayload:largePayload];
+}
+
 - (BOOL)needsSilentBadgeUpdate:(NSDictionary *)payload {
   // TODO: refactor this check by introducing
   // badgeOnly property in iOS notification payload
@@ -519,6 +574,10 @@
   return payload[collapseIDKey];
 }
 
+- (BOOL)isLargeNotification:(NSDictionary *)payload {
+  return payload[blobHashKey] && payload[encryptionKeyLabel];
+}
+
 - (UNNotificationContent *)getBadgeOnlyContentFor:
     (UNNotificationContent *)content {
   UNMutableNotificationContent *badgeOnlyContent =
@@ -799,11 +858,55 @@
   return memorySource;
 }
 
+// AES Cryptography
+static AESCryptoModuleObjCCompat *_aesCryptoModule = nil;
+
++ (AESCryptoModuleObjCCompat *)processLocalAESCryptoModule {
+  return _aesCryptoModule;
+}
+
++ (NSDictionary *)aesDecryptAndParse:(NSData *)sealedData
+                             withKey:(NSData *)key {
+  NSError *decryptError = nil;
+  NSInteger destinationLength =
+      [[NotificationService processLocalAESCryptoModule]
+          decryptedLength:sealedData];
+
+  NSMutableData *destination = [NSMutableData dataWithLength:destinationLength];
+  [[NotificationService processLocalAESCryptoModule]
+      decryptWithKey:key
+          sealedData:sealedData
+         destination:destination
+           withError:&decryptError];
+
+  if (decryptError) {
+    comm::Logger::log(
+        "NSE: Notification aes decryption failure. Details: " +
+        std::string([decryptError.localizedDescription UTF8String]));
+    return nil;
+  }
+
+  NSString *decryptedSerializedPayload =
+      [[NSString alloc] initWithData:destination encoding:NSUTF8StringEncoding];
+
+  return [NSJSONSerialization
+      JSONObjectWithData:[decryptedSerializedPayload
+                             dataUsingEncoding:NSUTF8StringEncoding]
+                 options:0
+                   error:nil];
+}
+
+// Process-local initialization code NSE may use different threads and instances
+// of this class to process notifs, but it usually keeps the same process for
+// extended period of time. Objects that can be initialized once and reused on
+// each notif should be declared in a method below to avoid wasting resources
+
 + (void)setUpNSEProcess {
   static dispatch_source_t memoryEventSource;
   static dispatch_once_t onceToken;
 
   dispatch_once(&onceToken, ^{
+    _aesCryptoModule = [[AESCryptoModuleObjCCompat alloc] init];
     memoryEventSource = [NotificationService registerForMemoryEvents];
   });
 }