Push Notification เป็นช่องทางสำคัญที่สุดในการดึงผู้ใช้กลับมาใช้แอปอีกครั้ง ในปี 2026 แอปที่ส่ง Push Notification อย่างชาญฉลาดมี Retention Rate สูงกว่าแอปที่ไม่ส่งถึง 3-10 เท่า แต่ถ้าส่งมากเกินไปหรือไม่ตรงเป้าหมาย ผู้ใช้จะปิด Notification หรือถอนการติดตั้งแอปทันที การออกแบบระบบ Push Notification และ Real-time ที่ดีจึงเป็นทักษะที่ Mobile Developer ต้องมี
บทความนี้จะพาคุณเข้าใจ Push Notification ตั้งแต่พื้นฐานจนถึง Implementation จริง ครอบคลุม FCM, APNs, Rich Notification, Deep Linking, Server-side Push, Real-time Features ด้วย WebSocket และ Firebase Realtime Database สำหรับผู้ที่ต้องการเข้าใจ Architecture ของ Mobile App ก่อน แนะนำอ่าน คู่มือ Mobile App Architecture ประกอบ
ประเภทของ Push Notification
Push Notification แบ่งออกเป็น 4 ประเภทหลัก แต่ละประเภทมีจุดประสงค์และวิธีจัดการที่แตกต่างกัน
1. Remote Push Notification
เป็น Notification ที่ส่งจาก Server ผ่าน Push Service (FCM / APNs) ไปยังอุปกรณ์ของผู้ใช้ ใช้สำหรับแจ้งเตือนข้อความใหม่ ข่าว โปรโมชั่น หรือ Event สำคัญ เป็นประเภทที่ใช้มากที่สุดในแอปทั่วไป
2. Local Notification
สร้างจากภายในแอปเอง ไม่ต้องผ่าน Server เหมาะสำหรับ Reminder, Alarm, ตั้งเวลาเตือน หรือแจ้งเตือนเมื่อทำ Task บางอย่างเสร็จ ทำงานได้แม้ไม่มี Internet
3. Rich Notification
Notification ที่แสดงเนื้อหามากกว่าแค่ข้อความ เช่น รูปภาพ ปุ่ม Action วิดีโอ หรือ UI แบบ Expandable ช่วยเพิ่ม Engagement ได้มากกว่า Text-only Notification
4. Silent Notification (Data-only)
Notification ที่ส่งมาแบบเงียบๆ ไม่แสดงอะไรบนหน้าจอ แต่ปลุกแอปให้ทำงานเบื้องหลัง เช่น Sync ข้อมูล อัพเดต Cache หรือเตรียมข้อมูลสำหรับการใช้งานครั้งต่อไป
| ประเภท | ต้องการ Server | ต้องการ Internet | แสดง UI | Use Case |
|---|---|---|---|---|
| Remote Push | ใช่ | ใช่ | ใช่ | ข้อความ, ข่าว, โปรโมชั่น |
| Local | ไม่ | ไม่ | ใช่ | Reminder, Alarm, Timer |
| Rich | ใช่ | ใช่ | ใช่ (พร้อมสื่อ) | ข่าว+รูป, ปุ่ม Action |
| Silent | ใช่ | ใช่ | ไม่ | Data Sync, Cache Update |
FCM (Firebase Cloud Messaging) — Setup
FCM เป็น Push Service ของ Google ที่ใช้ส่ง Notification ไปยังทั้ง Android และ iOS (ผ่าน APNs) รองรับทั้ง Individual, Topic-based และ Conditional messaging เป็นมาตรฐานที่ใช้มากที่สุดในปี 2026
Android Setup
// 1. เพิ่ม Firebase SDK (build.gradle.kts)
plugins {
id("com.google.gms.google-services")
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
}
// 2. สร้าง FirebaseMessagingService
class MyFirebaseMessagingService : FirebaseMessagingService() {
// เมื่อได้รับ Token ใหม่
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("FCM", "New token: $token")
// ส่ง Token ไป Server
sendTokenToServer(token)
}
// เมื่อได้รับ Notification
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
// Data payload — จัดการเองทั้งหมด
message.data.let { data ->
val type = data["type"]
val targetId = data["target_id"]
handleDataMessage(type, targetId, data)
}
// Notification payload — แสดงอัตโนมัติเมื่อ App อยู่ Background
message.notification?.let { notification ->
showNotification(
title = notification.title ?: "",
body = notification.body ?: "",
data = message.data
)
}
}
private fun showNotification(title: String, body: String, data: Map<String, String>) {
val channelId = data["channel"] ?: "default"
// Deep Link Intent
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("type", data["type"])
putExtra("target_id", data["target_id"])
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.notify(System.currentTimeMillis().toInt(), notification)
}
}
// 3. AndroidManifest.xml
// <service android:name=".MyFirebaseMessagingService"
// android:exported="false">
// <intent-filter>
// <action android:name="com.google.firebase.MESSAGING_EVENT" />
// </intent-filter>
// </service>
iOS Setup (Swift)
// AppDelegate.swift
import Firebase
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = self
// ขอ Permission
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { granted, error in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return true
}
// ได้รับ Device Token จาก APNs
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
// จัดการ Notification เมื่อ App อยู่ Foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound, .badge])
}
// จัดการเมื่อผู้ใช้กด Notification
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
handleDeepLink(from: userInfo)
completionHandler()
}
}
extension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging,
didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken else { return }
print("FCM Token: \(token)")
sendTokenToServer(token)
}
}
Flutter Setup
// Flutter — firebase_messaging
import 'package:firebase_messaging/firebase_messaging.dart';
// Background handler ต้องเป็น top-level function
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
print('Background message: ${message.messageId}');
// จัดการ Data Message ที่นี่
}
class PushNotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
Future<void> initialize() async {
// ขอ Permission (iOS)
NotificationSettings settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
print('Notification permission granted');
}
// รับ Token
String? token = await _messaging.getToken();
print('FCM Token: $token');
if (token != null) {
await _sendTokenToServer(token);
}
// Token Refresh
_messaging.onTokenRefresh.listen(_sendTokenToServer);
// Foreground Messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
print('Foreground message: ${message.notification?.title}');
_showLocalNotification(message);
});
// เมื่อกด Notification ตอน App อยู่ Background
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleDeepLink(message.data);
});
// เมื่อเปิด App จาก Notification (terminated state)
RemoteMessage? initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
_handleDeepLink(initialMessage.data);
}
// Background handler
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
}
Future<void> _sendTokenToServer(String token) async {
// API call ส่ง token ไป server
}
}
Notification Channels (Android) และ Categories (iOS)
ตั้งแต่ Android 8.0 (API 26) ทุกแอปต้องสร้าง Notification Channel เพื่อให้ผู้ใช้ควบคุมการแจ้งเตือนได้ละเอียดขึ้น ส่วน iOS ใช้ Categories สำหรับจัดกลุ่ม Action
// Android — Notification Channels
class NotificationChannelManager(private val context: Context) {
fun createChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
// ช่อง: ข้อความ Chat (สำคัญสูง)
val chatChannel = NotificationChannel(
"chat_messages",
"ข้อความแชท",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "แจ้งเตือนเมื่อมีข้อความใหม่"
enableLights(true)
lightColor = Color.BLUE
enableVibration(true)
vibrationPattern = longArrayOf(0, 250, 250, 250)
setSound(
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
)
}
// ช่อง: อัพเดตทั่วไป (สำคัญปกติ)
val updateChannel = NotificationChannel(
"general_updates",
"อัพเดตทั่วไป",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "ข่าวสาร อัพเดต ฟีเจอร์ใหม่"
}
// ช่อง: โปรโมชั่น (สำคัญต่ำ)
val promoChannel = NotificationChannel(
"promotions",
"โปรโมชั่นและข้อเสนอ",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "โปรโมชั่น ส่วนลด ข้อเสนอพิเศษ"
}
manager.createNotificationChannels(
listOf(chatChannel, updateChannel, promoChannel)
)
}
}
}
// iOS — Notification Categories + Actions
func registerNotificationCategories() {
// Action: ตอบกลับ
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY_ACTION",
title: "ตอบกลับ",
options: [],
textInputButtonTitle: "ส่ง",
textInputPlaceholder: "พิมพ์ข้อความ..."
)
// Action: Like
let likeAction = UNNotificationAction(
identifier: "LIKE_ACTION",
title: "ถูกใจ",
options: []
)
// Action: Mark as Read
let readAction = UNNotificationAction(
identifier: "MARK_READ_ACTION",
title: "อ่านแล้ว",
options: .destructive
)
// Category: Chat Message
let chatCategory = UNNotificationCategory(
identifier: "CHAT_MESSAGE",
actions: [replyAction, likeAction, readAction],
intentIdentifiers: [],
options: .customDismissAction
)
UNUserNotificationCenter.current().setNotificationCategories([chatCategory])
}
Notification Payload Structure
การออกแบบ Payload ที่ดีเป็นกุญแจสำคัญ FCM รองรับ 2 ประเภท Payload คือ notification (แสดงอัตโนมัติ) และ data (จัดการเอง) แนะนำให้ใช้ data-only เสมอเพื่อควบคุมการแสดงผลได้เต็มที่
// Notification Payload — แสดงอัตโนมัติ (ควบคุมน้อย)
{
"message": {
"token": "device-fcm-token",
"notification": {
"title": "ข้อความใหม่",
"body": "สวัสดีครับ มีอะไรอัพเดตบ้าง?"
}
}
}
// Data-only Payload — ควบคุมเต็มที่ (แนะนำ)
{
"message": {
"token": "device-fcm-token",
"data": {
"type": "chat_message",
"title": "ข้อความจาก John",
"body": "สวัสดีครับ มีอะไรอัพเดตบ้าง?",
"sender_id": "user-123",
"chat_id": "chat-456",
"sender_avatar": "https://example.com/avatar.jpg",
"timestamp": "2026-04-10T14:30:00Z",
"deep_link": "myapp://chat/chat-456"
},
"android": {
"priority": "high"
},
"apns": {
"payload": {
"aps": {
"content-available": 1
}
},
"headers": {
"apns-priority": "10"
}
}
}
}
Server-side Push Implementation (Node.js)
// server/push-notification.ts
import admin from 'firebase-admin';
// Initialize Firebase Admin
admin.initializeApp({
credential: admin.credential.cert('./service-account-key.json'),
});
interface PushPayload {
type: string;
title: string;
body: string;
data?: Record<string, string>;
imageUrl?: string;
}
// ส่งไปยังอุปกรณ์เดียว
async function sendToDevice(token: string, payload: PushPayload) {
const message: admin.messaging.Message = {
token,
data: {
type: payload.type,
title: payload.title,
body: payload.body,
image_url: payload.imageUrl || '',
...payload.data,
},
android: {
priority: 'high',
ttl: 86400 * 1000, // 24 ชั่วโมง
},
apns: {
payload: {
aps: {
'content-available': 1,
sound: 'default',
badge: 1,
},
},
headers: {
'apns-priority': '10',
'apns-expiration': String(Math.floor(Date.now() / 1000) + 86400),
},
},
};
try {
const response = await admin.messaging().send(message);
console.log('Push sent:', response);
return { success: true, messageId: response };
} catch (error: any) {
console.error('Push failed:', error);
// Token หมดอายุ — ลบออกจาก DB
if (error.code === 'messaging/registration-token-not-registered') {
await removeInvalidToken(token);
}
return { success: false, error: error.message };
}
}
// ส่งไปยังหลายอุปกรณ์ (Batch)
async function sendToMultipleDevices(tokens: string[], payload: PushPayload) {
const message: admin.messaging.MulticastMessage = {
tokens,
data: {
type: payload.type,
title: payload.title,
body: payload.body,
...payload.data,
},
android: { priority: 'high' },
apns: {
payload: { aps: { 'content-available': 1, sound: 'default' } },
},
};
const response = await admin.messaging().sendEachForMulticast(message);
// จัดการ Token ที่ Fail
response.responses.forEach((res, index) => {
if (!res.success && res.error?.code === 'messaging/registration-token-not-registered') {
removeInvalidToken(tokens[index]);
}
});
return {
successCount: response.successCount,
failureCount: response.failureCount,
};
}
Topic-based Notifications
Topic-based Notification ช่วยให้คุณส่ง Push ไปยังกลุ่มผู้ใช้ที่สนใจเรื่องเดียวกัน โดยไม่ต้องเก็บ Token ของทุกคน
// Client — Subscribe to Topic (Flutter)
await FirebaseMessaging.instance.subscribeToTopic('news_tech');
await FirebaseMessaging.instance.subscribeToTopic('deals_bangkok');
// Unsubscribe
await FirebaseMessaging.instance.unsubscribeFromTopic('deals_bangkok');
// Server — ส่งไป Topic
async function sendToTopic(topic: string, payload: PushPayload) {
const message: admin.messaging.Message = {
topic,
data: {
type: payload.type,
title: payload.title,
body: payload.body,
...payload.data,
},
};
return admin.messaging().send(message);
}
// ส่งแบบ Condition (AND, OR)
async function sendToCondition(payload: PushPayload) {
const message: admin.messaging.Message = {
// ผู้ใช้ที่ subscribe ทั้ง 'news' AND 'tech'
condition: "'news' in topics && 'tech' in topics",
data: {
type: payload.type,
title: payload.title,
body: payload.body,
},
};
return admin.messaging().send(message);
}
Token Management
Token Management เป็นหัวใจของระบบ Push Notification เพราะ FCM Token สามารถเปลี่ยนได้ตลอดเวลา (อัพเดตแอป ล้างข้อมูล เปลี่ยนอุปกรณ์) ถ้าไม่จัดการดี จะส่ง Push ไม่ถึงหรือส่งไปอุปกรณ์ที่ไม่มีแอปแล้ว
// Server — Token Management Service
interface DeviceToken {
userId: string;
token: string;
platform: 'android' | 'ios';
appVersion: string;
lastActive: Date;
createdAt: Date;
}
class TokenService {
// บันทึก Token ใหม่ หรืออัพเดต Token เดิม
async registerToken(userId: string, token: string, platform: string) {
// ลบ Token เดิมของ Device นี้ (ถ้ามี)
await db.deviceTokens.deleteMany({ token });
// บันทึก Token ใหม่
await db.deviceTokens.upsert({
where: { userId_platform: { userId, platform } },
create: { userId, token, platform, lastActive: new Date() },
update: { token, lastActive: new Date() },
});
}
// ลบ Token ที่หมดอายุ
async removeInvalidToken(token: string) {
await db.deviceTokens.deleteMany({ where: { token } });
}
// ดึง Token ทั้งหมดของ User
async getUserTokens(userId: string): Promise<string[]> {
const devices = await db.deviceTokens.findMany({
where: { userId },
select: { token: true },
});
return devices.map(d => d.token);
}
// ลบ Token ที่ไม่ Active นานเกิน 30 วัน
async cleanupStaleTokens() {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const result = await db.deviceTokens.deleteMany({
where: { lastActive: { lt: thirtyDaysAgo } },
});
console.log(`Cleaned ${result.count} stale tokens`);
}
}
Deep Linking จาก Notification
Deep Linking ช่วยให้ผู้ใช้กด Notification แล้วไปถึงหน้าจอที่ถูกต้องทันที ไม่ใช่ไปหน้า Home แล้วต้องนำทางเอง สำหรับเรื่อง WebSocket และ Real-time ที่เกี่ยวข้อง ดูที่ คู่มือ WebSocket
// Android — Deep Link Handler
class DeepLinkHandler(private val navController: NavController) {
fun handleNotificationData(data: Map<String, String>) {
val type = data["type"] ?: return
val targetId = data["target_id"] ?: return
when (type) {
"chat_message" -> {
navController.navigate("chat/$targetId")
}
"order_update" -> {
navController.navigate("order/$targetId")
}
"new_follower" -> {
navController.navigate("profile/$targetId")
}
"promo" -> {
val url = data["promo_url"] ?: return
navController.navigate("webview?url=$url")
}
else -> {
navController.navigate("home")
}
}
}
}
// Flutter — Deep Link from Notification
void _handleDeepLink(Map<String, dynamic> data) {
final type = data['type'] as String?;
final targetId = data['target_id'] as String?;
switch (type) {
case 'chat_message':
router.push('/chat/$targetId');
break;
case 'order_update':
router.push('/order/$targetId');
break;
case 'new_follower':
router.push('/profile/$targetId');
break;
default:
router.push('/');
}
}
Rich Notifications
Rich Notification เพิ่ม Engagement ได้มากกว่า Text-only Notification ถึง 56% ตามสถิติปี 2026 รองรับรูปภาพ ปุ่ม Action และ Expandable Content
Android — Rich Notification
// Big Picture Style (แสดงรูปภาพ)
fun showRichNotification(title: String, body: String, imageUrl: String) {
// โหลดรูปภาพก่อน
val bitmap = Glide.with(context)
.asBitmap()
.load(imageUrl)
.submit()
.get()
val notification = NotificationCompat.Builder(context, "general")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setLargeIcon(bitmap)
.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null as Bitmap?)
)
// Action Buttons
.addAction(R.drawable.ic_reply, "ตอบกลับ", replyPendingIntent)
.addAction(R.drawable.ic_like, "ถูกใจ", likePendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
// Messaging Style (สำหรับ Chat)
fun showChatNotification(messages: List<ChatMessage>) {
val person = Person.Builder()
.setName(messages.first().senderName)
.setIcon(IconCompat.createWithBitmap(avatarBitmap))
.build()
val style = NotificationCompat.MessagingStyle(person)
.setConversationTitle("กลุ่ม Developer Thailand")
messages.forEach { msg ->
style.addMessage(msg.text, msg.timestamp, person)
}
val notification = NotificationCompat.Builder(context, "chat_messages")
.setSmallIcon(R.drawable.ic_chat)
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.build()
NotificationManagerCompat.from(context).notify(chatId, notification)
}
iOS — Notification Service Extension (Rich Media)
// NotificationService.swift (Notification Service Extension)
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let content = bestAttemptContent,
let imageUrlString = content.userInfo["image_url"] as? String,
let imageUrl = URL(string: imageUrlString) else {
contentHandler(request.content)
return
}
// ดาวน์โหลดรูปภาพ
downloadImage(from: imageUrl) { localUrl in
if let localUrl = localUrl {
let attachment = try? UNNotificationAttachment(
identifier: "image",
url: localUrl,
options: nil
)
if let attachment = attachment {
content.attachments = [attachment]
}
}
contentHandler(content)
}
}
private func downloadImage(from url: URL, completion: @escaping (URL?) -> Void) {
URLSession.shared.downloadTask(with: url) { localUrl, _, _ in
guard let localUrl = localUrl else {
completion(nil)
return
}
let tmpUrl = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".jpg")
try? FileManager.default.moveItem(at: localUrl, to: tmpUrl)
completion(tmpUrl)
}.resume()
}
}
Silent Notifications — Background Data Sync
// Android — Data-only Message (Background)
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["type"] == "data_sync") {
// Sync ข้อมูลเบื้องหลัง ไม่แสดง Notification
CoroutineScope(Dispatchers.IO).launch {
val syncType = message.data["sync_type"]
when (syncType) {
"products" -> productRepository.syncFromServer()
"settings" -> settingsRepository.refreshSettings()
"messages" -> messageRepository.fetchNewMessages()
}
}
}
}
// iOS — Silent Push (content-available)
// Server Payload:
// {
// "aps": { "content-available": 1 },
// "sync_type": "products"
// }
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler:
@escaping (UIBackgroundFetchResult) -> Void) {
let syncType = userInfo["sync_type"] as? String ?? ""
Task {
do {
switch syncType {
case "products":
try await ProductRepository.shared.syncFromServer()
case "settings":
try await SettingsRepository.shared.refresh()
default:
break
}
completionHandler(.newData)
} catch {
completionHandler(.failed)
}
}
}
Notification Analytics
// Notification Analytics Tracker
class NotificationAnalytics {
// Track เมื่อส่ง Push
async trackSent(notificationId: string, userId: string, type: string) {
await db.notificationEvents.create({
data: {
notificationId,
userId,
type,
event: 'sent',
timestamp: new Date(),
},
});
}
// Track เมื่อ Push ถูก Delivered
async trackDelivered(notificationId: string) {
await db.notificationEvents.create({
data: { notificationId, event: 'delivered', timestamp: new Date() },
});
}
// Track เมื่อผู้ใช้กด Notification (Open)
async trackOpened(notificationId: string) {
await db.notificationEvents.create({
data: { notificationId, event: 'opened', timestamp: new Date() },
});
}
// คำนวณ Metrics
async getMetrics(startDate: Date, endDate: Date) {
const sent = await db.notificationEvents.count({
where: { event: 'sent', timestamp: { gte: startDate, lte: endDate } },
});
const delivered = await db.notificationEvents.count({
where: { event: 'delivered', timestamp: { gte: startDate, lte: endDate } },
});
const opened = await db.notificationEvents.count({
where: { event: 'opened', timestamp: { gte: startDate, lte: endDate } },
});
return {
sent,
delivered,
opened,
deliveryRate: delivered / sent * 100, // อัตราส่งสำเร็จ
openRate: opened / delivered * 100, // อัตราเปิดอ่าน
ctr: opened / sent * 100, // Click-Through Rate
};
}
}
หลีกเลี่ยง Notification Fatigue
Notification Fatigue เกิดขึ้นเมื่อผู้ใช้ได้รับ Push Notification มากเกินไปจนเริ่มไม่สนใจ ปิด Notification หรือถอนการติดตั้งแอป กฎที่ต้องจำคือ ส่งน้อยแต่ตรงเป้า ดีกว่าส่งมากแต่ไม่ตรงใจ
- จำกัดจำนวน: ไม่ส่งเกิน 3-5 ครั้งต่อวัน (ยกเว้น Chat Message)
- Timing: ส่งในเวลาที่เหมาะสม ไม่ส่งตอนดึก (21:00-07:00) ใช้ Timezone ของผู้ใช้
- Personalization: ส่งตามพฤติกรรมของผู้ใช้ ไม่ใช่ส่งแบบ Broadcast ทุกคน
- Frequency Capping: ตั้งขีดจำกัดว่า User แต่ละคนรับ Push ได้กี่ครั้งต่อช่วงเวลา
- Opt-in/Opt-out: ให้ผู้ใช้เลือกประเภท Notification ที่ต้องการรับ
- A/B Testing: ทดสอบ Title, Body, Timing เพื่อหาสูตรที่ Open Rate สูงสุด
// Frequency Capping Service
class NotificationThrottler {
private readonly MAX_PER_DAY = 5;
private readonly MAX_PER_HOUR = 2;
private readonly QUIET_HOURS = { start: 21, end: 7 };
async canSend(userId: string, type: string): Promise<boolean> {
// Chat messages ไม่จำกัด
if (type === 'chat_message') return true;
// ตรวจสอบ Quiet Hours (ใช้ Timezone ของ User)
const userTz = await this.getUserTimezone(userId);
const userHour = this.getCurrentHourInTimezone(userTz);
if (userHour >= this.QUIET_HOURS.start || userHour < this.QUIET_HOURS.end) {
return false;
}
// ตรวจสอบ Rate Limit
const sentToday = await this.getCountSince(userId, 'day');
if (sentToday >= this.MAX_PER_DAY) return false;
const sentThisHour = await this.getCountSince(userId, 'hour');
if (sentThisHour >= this.MAX_PER_HOUR) return false;
return true;
}
}
Real-time Features สำหรับ Mobile App
นอกจาก Push Notification แล้ว Real-time Features เป็นสิ่งที่ผู้ใช้คาดหวังในแอปสมัยใหม่ ไม่ว่าจะเป็น Chat, Live Updates, Real-time Collaboration หรือ Live Tracking สำหรับการเข้าใจ Event-driven Architecture ที่เป็นพื้นฐาน ดูที่ คู่มือ Event-driven Architecture
WebSocket สำหรับ Chat
// Flutter — WebSocket Chat Client
class ChatWebSocket {
late WebSocketChannel _channel;
final StreamController<ChatMessage> _messageController =
StreamController<ChatMessage>.broadcast();
Stream<ChatMessage> get messages => _messageController.stream;
void connect(String userId, String authToken) {
_channel = WebSocketChannel.connect(
Uri.parse('wss://api.example.com/ws/chat?token=$authToken'),
);
_channel.stream.listen(
(data) {
final json = jsonDecode(data);
switch (json['type']) {
case 'message':
final msg = ChatMessage.fromJson(json['payload']);
_messageController.add(msg);
break;
case 'typing':
_handleTypingIndicator(json['payload']);
break;
case 'read_receipt':
_handleReadReceipt(json['payload']);
break;
}
},
onError: (error) {
print('WebSocket error: $error');
_reconnect(userId, authToken);
},
onDone: () {
print('WebSocket closed');
_reconnect(userId, authToken);
},
);
}
void sendMessage(String chatId, String text) {
_channel.sink.add(jsonEncode({
'type': 'message',
'payload': {
'chat_id': chatId,
'text': text,
'timestamp': DateTime.now().toIso8601String(),
},
}));
}
void sendTypingIndicator(String chatId) {
_channel.sink.add(jsonEncode({
'type': 'typing',
'payload': { 'chat_id': chatId },
}));
}
void _reconnect(String userId, String authToken) {
Future.delayed(Duration(seconds: 3), () {
connect(userId, authToken);
});
}
void dispose() {
_channel.sink.close();
_messageController.close();
}
}
Server-Sent Events (SSE) สำหรับ Live Data
// React Native — SSE for Live Price Updates
import EventSource from 'react-native-sse';
function useLivePrices() {
const [prices, setPrices] = useState<PriceData[]>([]);
useEffect(() => {
const es = new EventSource('https://api.example.com/sse/prices', {
headers: {
Authorization: `Bearer ${token}`,
},
});
es.addEventListener('price_update', (event) => {
const data = JSON.parse(event.data);
setPrices((prev) =>
prev.map((p) =>
p.symbol === data.symbol ? { ...p, price: data.price } : p
)
);
});
es.addEventListener('market_status', (event) => {
const data = JSON.parse(event.data);
// อัพเดตสถานะตลาด
});
es.addEventListener('error', (error) => {
console.error('SSE error:', error);
});
return () => es.close();
}, []);
return prices;
}
// Node.js Server — SSE Endpoint
app.get('/sse/prices', authenticate, (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const interval = setInterval(() => {
const priceData = getCurrentPrices();
res.write(`event: price_update\n`);
res.write(`data: ${JSON.stringify(priceData)}\n\n`);
}, 1000);
req.on('close', () => {
clearInterval(interval);
res.end();
});
});
Firebase Realtime Database
// Flutter — Firebase Realtime Database
class LiveOrderTracker {
final DatabaseReference _ordersRef =
FirebaseDatabase.instance.ref('orders');
// ติดตาม Order Status แบบ Real-time
Stream<OrderStatus> watchOrderStatus(String orderId) {
return _ordersRef
.child(orderId)
.child('status')
.onValue
.map((event) {
final data = event.snapshot.value as Map?;
return OrderStatus.fromJson(data ?? {});
});
}
// อัพเดต Location ของ Driver (Delivery)
Future<void> updateDriverLocation(
String orderId,
double lat,
double lng,
) async {
await _ordersRef.child(orderId).child('driver_location').set({
'lat': lat,
'lng': lng,
'updated_at': ServerValue.timestamp,
});
}
// Presence System (Online/Offline)
void setupPresence(String userId) {
final userStatusRef =
FirebaseDatabase.instance.ref('status/$userId');
final connectedRef =
FirebaseDatabase.instance.ref('.info/connected');
connectedRef.onValue.listen((event) {
if (event.snapshot.value == true) {
userStatusRef.onDisconnect().set({
'state': 'offline',
'last_seen': ServerValue.timestamp,
});
userStatusRef.set({
'state': 'online',
'last_seen': ServerValue.timestamp,
});
}
});
}
}
Real-time + Push Combination Patterns
ในแอปจริง Real-time (WebSocket) และ Push Notification ทำงานร่วมกัน โดยมีหลักการว่า: เมื่อ App อยู่ Foreground ใช้ WebSocket เมื่อ App อยู่ Background ใช้ Push Notification
// Hybrid Notification Manager
class HybridNotificationManager {
private isAppForeground = false;
private wsClient: WebSocket | null = null;
// Server ตัดสินใจว่าจะส่งผ่านทางไหน
async notifyUser(userId: string, notification: NotificationPayload) {
// ตรวจสอบว่า User มี Active WebSocket Connection หรือไม่
const hasActiveWs = await this.wsManager.isUserConnected(userId);
if (hasActiveWs) {
// App อยู่ Foreground — ส่งผ่าน WebSocket (เร็วกว่า)
await this.wsManager.sendToUser(userId, {
type: 'notification',
payload: notification,
});
} else {
// App อยู่ Background — ส่งผ่าน Push Notification
const tokens = await this.tokenService.getUserTokens(userId);
await this.pushService.sendToDevices(tokens, notification);
}
}
}
Testing Push Notifications
// ทดสอบ Push ด้วย Firebase CLI
// 1. ติดตั้ง Firebase CLI
npm install -g firebase-tools
// 2. ส่ง Test Push
firebase messaging:send --project your-project-id \
--json '{
"message": {
"token": "device-token-here",
"data": {
"type": "test",
"title": "ทดสอบ Push",
"body": "นี่คือ Push ทดสอบ"
}
}
}'
// Unit Test — Push Service
describe('PushNotificationService', () => {
it('should send push to valid token', async () => {
const mockSend = jest.spyOn(admin.messaging(), 'send')
.mockResolvedValue('message-id-123');
const result = await pushService.sendToDevice('valid-token', {
type: 'test',
title: 'Test',
body: 'Test body',
});
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
token: 'valid-token',
})
);
});
it('should remove invalid token', async () => {
jest.spyOn(admin.messaging(), 'send')
.mockRejectedValue({
code: 'messaging/registration-token-not-registered',
});
const removeSpy = jest.spyOn(tokenService, 'removeInvalidToken');
await pushService.sendToDevice('invalid-token', payload);
expect(removeSpy).toHaveBeenCalledWith('invalid-token');
});
});
Notification Permission Best Practices
การขอ Permission อย่างชาญฉลาดเป็นกุญแจสำคัญ ถ้าขอตอนเปิดแอปครั้งแรกโดยไม่อธิบาย ผู้ใช้ส่วนใหญ่จะกด "ไม่อนุญาต" และเปลี่ยนใจทีหลังยากมาก
- Pre-permission Screen: แสดงหน้าจออธิบายว่า Notification จะส่งอะไร และประโยชน์ที่ได้รับ ก่อนที่จะขอ Permission จริง
- Contextual Timing: ขอ Permission ตอนที่ผู้ใช้ทำสิ่งที่เกี่ยวข้อง เช่น หลังจากสั่งซื้อสินค้า (เพื่อแจ้งสถานะ) หรือหลังจากส่งข้อความ (เพื่อแจ้งเตือนตอบกลับ)
- Gradual Permission: เริ่มจากขอ Permission น้อย แล้วค่อยขอเพิ่มตามการใช้งาน
- Fallback: ถ้าผู้ใช้ปฏิเสธ ให้มี In-app Notification แทน
// Pre-permission Pattern (Flutter)
class NotificationPermissionScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.notifications_active, size: 80, color: Colors.blue),
SizedBox(height: 24),
Text(
'ไม่พลาดทุกอัพเดต',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
Padding(
padding: EdgeInsets.symmetric(horizontal: 40),
child: Text(
'เปิดแจ้งเตือนเพื่อรับ:\n'
'- สถานะคำสั่งซื้อแบบ Real-time\n'
'- ข้อความจากร้านค้า\n'
'- โปรโมชั่นพิเศษเฉพาะคุณ',
textAlign: TextAlign.center,
),
),
SizedBox(height: 32),
ElevatedButton(
onPressed: () async {
final settings = await FirebaseMessaging.instance
.requestPermission();
if (settings.authorizationStatus ==
AuthorizationStatus.authorized) {
// Permission granted
Navigator.pushReplacement(context,
MaterialPageRoute(builder: (_) => HomeScreen()));
}
},
child: Text('เปิดแจ้งเตือน'),
),
TextButton(
onPressed: () {
// Skip — ใช้ In-app notification แทน
Navigator.pushReplacement(context,
MaterialPageRoute(builder: (_) => HomeScreen()));
},
child: Text('ไว้ทีหลัง'),
),
],
),
);
}
}
Notification Scheduling
// Local Notification Scheduling (Flutter — flutter_local_notifications)
class NotificationScheduler {
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
}) async {
await _plugin.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDate, tz.local),
NotificationDetails(
android: AndroidNotificationDetails(
'reminders', 'Reminders',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time, // ทำซ้ำทุกวัน
);
}
// ยกเลิก Notification
Future<void> cancelNotification(int id) async {
await _plugin.cancel(id);
}
// ยกเลิกทั้งหมด
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
}
สรุป — Push Notification & Real-time Checklist
| หัวข้อ | เทคโนโลยี | คำแนะนำ |
|---|---|---|
| Push Service | FCM + APNs | ใช้ FCM เป็นหลัก รองรับทั้ง Android + iOS |
| Payload | Data-only | ใช้ Data-only เพื่อควบคุมการแสดงผลเต็มที่ |
| Token Management | Server-side DB | Refresh Token สม่ำเสมอ ลบ Token หมดอายุ |
| Deep Linking | Custom URL Scheme | ทุก Push ต้องมี Deep Link ไปหน้าจอที่ถูกต้อง |
| Rich Notification | BigPicture, Service Extension | เพิ่ม Engagement ด้วยรูปภาพและปุ่ม Action |
| Real-time Chat | WebSocket | ใช้ WebSocket สำหรับ Chat, ส่งผ่าน Push เมื่อ Offline |
| Live Data | SSE / Firebase RTDB | SSE สำหรับ One-way, Firebase RTDB สำหรับ Sync |
| Analytics | Custom + Firebase | Track Sent, Delivered, Opened, CTR |
| Frequency | Throttling Service | จำกัดจำนวน Push ต่อวัน/ชั่วโมง |
| Permission | Pre-permission Screen | อธิบายก่อนขอ ให้ทางเลือก Skip |
Push Notification และ Real-time เป็นฟีเจอร์ที่ทำให้แอปมีชีวิต ผู้ใช้รู้สึกว่าแอปทำงานเพื่อเขาอยู่ตลอดเวลา แต่ถ้าใช้ไม่ดีจะเป็นดาบสองคม สิ่งสำคัญคือ ส่งสิ่งที่ผู้ใช้ต้องการ ในเวลาที่เหมาะสม ไปยังคนที่ใช่ สำหรับ Backend Architecture ที่เกี่ยวข้อง อ่านเพิ่มที่ คู่มือ TypeScript + Node.js และ คู่มือ Microservices
