This article was originally published on + @Transactional Trap
This one nearly cost us a day.
Our push delivery runs inside
@Asyncmethods. When a token needs to be soft-deleted (delivery failure), we need a database transaction. The natural instinct is to extract a@Transactionalprivate method.
This does not work.
Spring's
@Transactionalrelies on AOP proxies. When you call a@Transactionalmethod from within the same bean, the call goes throughthis, not through the proxy. The annotation is silently ignored. Your "transaction" is actually running without one.
Inside an
@Asyncmethod, you're already past the proxy boundary. Internal calls to@Transactionalmethods are no-ops.
The fix:
TransactionTemplate. Programmatic transaction management that works regardless of proxy context.
CODEvoid softDeleteToken(UserPushTokenEntity token, String reason) {
transactionTemplate.executeWithoutResult(status -> {
userPushTokenRepository.hardDeleteSoftDeletedByPushToken(token.getPushToken());
token.setDeleted(true);
token.setDeleteReason(reason);
userPushTokenRepository.save(token);
});
}
Not glamorous. But it actually works. Every time.
The Mobile Side: Less Drama, More Plumbing
The React Native side was comparatively calm. A single
usePushNotificationshook handles everything:
Permission request —Notifications.getPermissionsAsync()thenrequestPermissionsAsync()if needed
Token retrieval —Notifications.getDevicePushTokenAsync()(native token, not Expo token)
Backend registration — RTK Query mutation, best-effort (silently fails if backend is unreachable)
Foreground handling —setNotificationHandlerto show banners even when the app is open
Cache invalidation — When a push arrives in the foreground, we invalidate RTK Query'sUnreadCountcache tag. TheNotificationBellre-renders with the fresh count. No polling needed.
Tap routing — When the user taps a notification, we extractactionUrlfrom the payload data androuter.push()to the right screen
The hook stores the push token in a module-level variable (not React state, not Redux). Why? Because during logout, React state may be mid-teardown and Redux may be mid-reset. A simple module variable survives both.
The NotificationBell: From Polling to Push
Before:
CODEconst { data } = useGetUnreadCountQuery(undefined, {
skip: !isAuthenticated,
pollingInterval: 30000, // The sin
});
After:
CODEconst { data, refetch } = useGetUnreadCountQuery(undefined, {
skip: !isAuthenticated,
// No polling. Push notifications invalidate the cache.
});
// Only refetch when user returns to the app (tab switch, unlock)
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (appState.current !== 'active' && next === 'active' && isAuthenticated) {
refetch();
}
appState.current = next;
});
return () => sub.remove();
}, [isAuthenticated, refetch]);
The difference: from 2,880 requests/day to maybe 20-30 (one per app foreground event). Server load dropped. Battery usage dropped. And notifications arrive instantly instead of up to 30 seconds late.
What We Shipped
Aspect
Before
After
Delivery mechanism
HTTP polling (30s)
FCM (Android) + APNs (iOS)
Background delivery
None
Full system-level push
Latency
0-30 seconds
Sub-second
Requests per user/day
~2,880
~20-30
Third-party dependency
None
None (direct to Apple/Google)
Token management
N/A
Auto-cleanup on delivery failure
Foreground behavior
Badge update on next poll
Instant banner + badge + sound
Lessons Learned
Expo Push Service is good. Direct is better. If you're serious about push reliability and payload control, go direct. The implementation cost is a few hundred lines of JWT plumbing.
@Asyncand@Transactionaldon't compose. UseTransactionTemplatefor programmatic transactions inside async methods. This isn't a Spring bug — it's how AOP proxies work.
Soft-delete + unique constraints need careful choreography. Partial unique indexes (WHERE deleted = false) are powerful but require hard-deleting stale soft-deleted rows before creating new ones.
Store push tokens outside React state for logout. Module-level variables are ugly but survive the teardown chaos of a logout flow.
getDevicePushTokenAsync>getExpoPushTokenAsyncif you're doing direct FCM/APNs. Skip the Expo token abstraction layer.
HTTP/2 is mandatory for APNs. Ensure your HTTP client is configured for it explicitly. Silent failures here are painful to debug.
Have you made the polling-to-push jump? What surprised you the most? Drop a comment below.
Building jo4.io — a modern URL shortener with analytics, bio pages, and an affiliate marketplace for creators.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
From 30-Second Polling to Real Push Notifications
- ▸ The Setup: How We Got Here
- ▸ The "Obvious" Solution: Expo Push Service
- ▸ The Pivot: Direct FCM + APNs
- ▸ The Backend: Two JWT Dialects
- ↳ FCM (Android): RSA-256 OAuth Dance
- ↳ APNs (iOS): EC-256 Provider Token
- ▸ The Token Lifecycle Problem
- ▸ The @async + @Transactional Trap
- ▸ The Mobile Side: Less Drama, More Plumbing
- ▸ The NotificationBell: From Polling to Push
- ▸ What We Shipped
- ▸ Lessons Learned
SOCIAL SHARE CARD GENERATOR