Progressive Web Apps with Java Backend

Overview

Progressive Web Apps (PWAs) combined with Java backend provide native-app-like experiences with web technologies. This guide covers building PWAs with Java backend services.

Architecture

Frontend (PWA) ↔ Java Backend ↔ Database
ↆ
Service Worker
ↆ
Cache API

1. Java Backend Setup

Spring Boot Configuration

// pom.xml dependencies
/*
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
*/
@SpringBootApplication
public class PwaBackendApplication {
public static void main(String[] args) {
SpringApplication.run(PwaBackendApplication.class, args);
}
}

CORS Configuration

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("https://your-pwa-domain.com", "http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Serve static PWA files
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.setCachePeriod(3600)
.resourceChain(true)
.addResolver(new PathResourceResolver());
}
}

2. PWA Manifest and Service Worker Controller

Manifest Controller

@RestController
@RequestMapping("/api/manifest")
public class ManifestController {
@GetMapping(value = "/web-app-manifest.json", produces = "application/json")
public Map<String, Object> getWebAppManifest() {
Map<String, Object> manifest = new LinkedHashMap<>();
// Basic manifest properties
manifest.put("name", "My Progressive Web App");
manifest.put("short_name", "MyPWA");
manifest.put("description", "A progressive web app with Java backend");
manifest.put("start_url", "/");
manifest.put("display", "standalone");
manifest.put("background_color", "#ffffff");
manifest.put("theme_color", "#000000");
manifest.put("orientation", "portrait-primary");
// Icons
List<Map<String, Object>> icons = new ArrayList<>();
icons.add(createIcon("icons/icon-72x72.png", "72x72", "image/png"));
icons.add(createIcon("icons/icon-96x96.png", "96x96", "image/png"));
icons.add(createIcon("icons/icon-128x128.png", "128x128", "image/png"));
icons.add(createIcon("icons/icon-144x144.png", "144x144", "image/png"));
icons.add(createIcon("icons/icon-152x152.png", "152x152", "image/png"));
icons.add(createIcon("icons/icon-192x192.png", "192x192", "image/png"));
icons.add(createIcon("icons/icon-384x384.png", "384x384", "image/png"));
icons.add(createIcon("icons/icon-512x512.png", "512x512", "image/png"));
manifest.put("icons", icons);
// Screenshots
List<Map<String, Object>> screenshots = new ArrayList<>();
screenshots.add(createScreenshot("screenshots/desktop.png", "1280x720", "wide"));
screenshots.add(createScreenshot("screenshots/mobile.png", "750x1334", "narrow"));
manifest.put("screenshots", screenshots);
// Categories
manifest.put("categories", Arrays.asList("business", "productivity"));
return manifest;
}
private Map<String, Object> createIcon(String src, String sizes, String type) {
Map<String, Object> icon = new HashMap<>();
icon.put("src", src);
icon.put("sizes", sizes);
icon.put("type", type);
icon.put("purpose", "any maskable");
return icon;
}
private Map<String, Object> createScreenshot(String src, String sizes, String form) {
Map<String, Object> screenshot = new HashMap<>();
screenshot.put("src", src);
screenshot.put("sizes", sizes);
screenshot.put("type", "image/png");
screenshot.put("form", form);
return screenshot;
}
}

Service Worker Registration Controller

@RestController
@RequestMapping("/api/service-worker")
public class ServiceWorkerController {
@GetMapping(value = "/sw.js", produces = "application/javascript")
public String getServiceWorker() {
return """
const CACHE_NAME = 'pwa-cache-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/app.js',
'/images/logo.png'
];
// Install event
self.addEventListener('install', event => {
console.log('Service Worker installing.');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
});
// Fetch event
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached version or fetch from network
return response || fetch(event.request);
})
);
});
// Activate event
self.addEventListener('activate', event => {
console.log('Service Worker activating.');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
""";
}
@PostMapping("/push/subscribe")
public ResponseEntity<?> subscribeToPush(@RequestBody PushSubscription subscription) {
// Store subscription in database
pushSubscriptionService.saveSubscription(subscription);
return ResponseEntity.ok().build();
}
@PostMapping("/push/send")
public ResponseEntity<?> sendPushNotification(@RequestBody PushMessage message) {
pushNotificationService.sendNotificationToAll(message);
return ResponseEntity.ok().build();
}
}

3. Data Models and Services

PWA Data Models

@Entity
@Table(name = "push_subscriptions")
public class PushSubscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "TEXT")
private String endpoint;
@Column(columnDefinition = "TEXT")
private String p256dh;
@Column(columnDefinition = "TEXT")
private String auth;
private LocalDateTime createdAt;
// Constructors, getters, setters
public PushSubscription() {
this.createdAt = LocalDateTime.now();
}
}
@Entity
@Table(name = "app_cache")
public class AppCache {
@Id
private String cacheKey;
@Column(columnDefinition = "TEXT")
private String cacheValue;
private LocalDateTime expiresAt;
private LocalDateTime lastAccessed;
// Constructors, getters, setters
}
@Entity
@Table(name = "offline_actions")
public class OfflineAction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String actionType; // CREATE, UPDATE, DELETE
private String entityType; // USER, ORDER, etc.
private String entityData; // JSON data
@Enumerated(EnumType.STRING)
private SyncStatus syncStatus;
private LocalDateTime createdAt;
private LocalDateTime syncedAt;
// Constructors, getters, setters
}
enum SyncStatus {
PENDING, SYNCED, FAILED
}

Service Layer

@Service
public class PushNotificationService {
@Value("${vapid.public.key}")
private String vapidPublicKey;
@Value("${vapid.private.key}")
private String vapidPrivateKey;
private final PushSubscriptionRepository subscriptionRepository;
public PushNotificationService(PushSubscriptionRepository subscriptionRepository) {
this.subscriptionRepository = subscriptionRepository;
}
public void sendNotificationToAll(PushMessage message) {
List<PushSubscription> subscriptions = subscriptionRepository.findAll();
for (PushSubscription subscription : subscriptions) {
sendNotification(subscription, message);
}
}
public void sendNotification(PushSubscription subscription, PushMessage message) {
try {
// Create JWT token for VAPID
String token = createVapidToken(subscription.getEndpoint());
// Create push message
PushService pushService = new PushService();
Notification notification = Notification.builder()
.title(message.getTitle())
.body(message.getBody())
.icon("/icons/icon-192x192.png")
.badge("/icons/badge-72x72.png")
.data(message.getData())
.build();
// Send notification (pseudo-code - use webpush library)
// pushService.send(subscription, notification, token);
} catch (Exception e) {
System.err.println("Failed to send push notification: " + e.getMessage());
}
}
private String createVapidToken(String audience) {
// Implement VAPID token creation
// This is a simplified version
return "vapid-token";
}
}
@Service
public class OfflineSyncService {
private final OfflineActionRepository actionRepository;
public OfflineSyncService(OfflineActionRepository actionRepository) {
this.actionRepository = actionRepository;
}
@Transactional
public OfflineAction queueAction(String actionType, String entityType, String entityData) {
OfflineAction action = new OfflineAction();
action.setActionType(actionType);
action.setEntityType(entityType);
action.setEntityData(entityData);
action.setSyncStatus(SyncStatus.PENDING);
return actionRepository.save(action);
}
@Transactional
public void processPendingActions() {
List<OfflineAction> pendingActions = actionRepository.findBySyncStatus(SyncStatus.PENDING);
for (OfflineAction action : pendingActions) {
try {
processAction(action);
action.setSyncStatus(SyncStatus.SYNCED);
action.setSyncedAt(LocalDateTime.now());
actionRepository.save(action);
} catch (Exception e) {
action.setSyncStatus(SyncStatus.FAILED);
actionRepository.save(action);
System.err.println("Failed to process action: " + action.getId());
}
}
}
private void processAction(OfflineAction action) {
// Process the action based on type and entity
switch (action.getActionType()) {
case "CREATE":
// Handle create operation
break;
case "UPDATE":
// Handle update operation
break;
case "DELETE":
// Handle delete operation
break;
}
}
public List<OfflineAction> getPendingActions() {
return actionRepository.findBySyncStatus(SyncStatus.PENDING);
}
}

4. REST API Controllers

PWA API Controller

@RestController
@RequestMapping("/api/pwa")
public class PwaApiController {
private final ProductService productService;
private final OfflineSyncService offlineSyncService;
public PwaApiController(ProductService productService, OfflineSyncService offlineSyncService) {
this.productService = productService;
this.offlineSyncService = offlineSyncService;
}
@GetMapping("/products")
public ResponseEntity<List<Product>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
List<Product> products = productService.getProducts(page, size);
// Add cache headers for PWA
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
.eTag(String.valueOf(products.hashCode()))
.body(products);
}
@PostMapping("/products")
public ResponseEntity<Product> createProduct(@RequestBody Product product) {
try {
Product savedProduct = productService.saveProduct(product);
return ResponseEntity.status(HttpStatus.CREATED).body(savedProduct);
} catch (Exception e) {
// Queue for offline sync
String productJson = convertToJson(product);
offlineSyncService.queueAction("CREATE", "PRODUCT", productJson);
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
}
}
@GetMapping("/sync/status")
public ResponseEntity<SyncStatusResponse> getSyncStatus() {
List<OfflineAction> pendingActions = offlineSyncService.getPendingActions();
SyncStatusResponse response = new SyncStatusResponse();
response.setPendingActionsCount(pendingActions.size());
response.setLastSyncAttempt(LocalDateTime.now());
response.setOnline(true);
return ResponseEntity.ok(response);
}
@PostMapping("/sync/now")
public ResponseEntity<?> triggerSync() {
offlineSyncService.processPendingActions();
return ResponseEntity.ok().build();
}
private String convertToJson(Object object) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(object);
} catch (Exception e) {
return "{}";
}
}
// DTO classes
public static class SyncStatusResponse {
private int pendingActionsCount;
private LocalDateTime lastSyncAttempt;
private boolean online;
// Getters and setters
}
}

Background Sync Controller

@RestController
@RequestMapping("/api/background-sync")
public class BackgroundSyncController {
private final BackgroundSyncService backgroundSyncService;
public BackgroundSyncController(BackgroundSyncService backgroundSyncService) {
this.backgroundSyncService = backgroundSyncService;
}
@PostMapping("/register")
public ResponseEntity<?> registerBackgroundSync(@RequestBody SyncRegistration registration) {
backgroundSyncService.registerSyncTask(registration);
return ResponseEntity.ok().build();
}
@GetMapping("/tasks")
public ResponseEntity<List<SyncTask>> getSyncTasks() {
List<SyncTask> tasks = backgroundSyncService.getPendingTasks();
return ResponseEntity.ok(tasks);
}
@PostMapping("/execute/{taskId}")
public ResponseEntity<?> executeSyncTask(@PathVariable String taskId) {
backgroundSyncService.executeTask(taskId);
return ResponseEntity.ok().build();
}
}
@Service
public class BackgroundSyncService {
private final Map<String, SyncTask> syncTasks = new ConcurrentHashMap<>();
public void registerSyncTask(SyncRegistration registration) {
SyncTask task = new SyncTask();
task.setId(UUID.randomUUID().toString());
task.setTag(registration.getTag());
task.setTaskData(registration.getData());
task.setCreatedAt(LocalDateTime.now());
task.setStatus(SyncTaskStatus.PENDING);
syncTasks.put(task.getId(), task);
}
public List<SyncTask> getPendingTasks() {
return syncTasks.values().stream()
.filter(task -> task.getStatus() == SyncTaskStatus.PENDING)
.collect(Collectors.toList());
}
public void executeTask(String taskId) {
SyncTask task = syncTasks.get(taskId);
if (task != null) {
try {
// Process the background sync task
processTask(task);
task.setStatus(SyncTaskStatus.COMPLETED);
task.setCompletedAt(LocalDateTime.now());
} catch (Exception e) {
task.setStatus(SyncTaskStatus.FAILED);
task.setError(e.getMessage());
}
}
}
private void processTask(SyncTask task) {
// Implement task processing logic
System.out.println("Processing background sync task: " + task.getTag());
}
}

5. PWA Frontend Integration

HTML Template with PWA Features

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My PWA</title>
<meta name="theme-color" content="#000000"/>
<meta name="description" content="Progressive Web App with Java Backend"/>
<!-- PWA Manifest -->
<link rel="manifest" href="/api/manifest/web-app-manifest.json">
<!-- Apple Touch Icon -->
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/api/service-worker/sw.js')
.then(function(registration) {
console.log('ServiceWorker registration successful');
})
.catch(function(error) {
console.log('ServiceWorker registration failed: ', error);
});
});
}
</script>
</head>
<body>
<div id="app">
<header>
<h1>My PWA</h1>
<div id="connection-status"></div>
</header>
<main>
<!-- App content -->
</main>
</div>
<script src="/js/app.js"></script>
</body>
</html>

JavaScript PWA Features

// app.js - PWA Frontend Logic
class PWAApp {
constructor() {
this.init();
}
init() {
this.registerServiceWorker();
this.setupOfflineDetection();
this.setupBackgroundSync();
this.setupPushNotifications();
this.loadAppData();
}
// Service Worker Registration
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/api/service-worker/sw.js');
console.log('SW registered: ', registration);
// Set up periodic sync
if ('periodicSync' in registration) {
this.setupPeriodicSync(registration);
}
} catch (error) {
console.error('SW registration failed: ', error);
}
}
}
// Offline Detection
setupOfflineDetection() {
window.addEventListener('online', () => {
this.updateConnectionStatus(true);
this.syncOfflineData();
});
window.addEventListener('offline', () => {
this.updateConnectionStatus(false);
});
// Initial status
this.updateConnectionStatus(navigator.onLine);
}
updateConnectionStatus(online) {
const statusElement = document.getElementById('connection-status');
if (statusElement) {
statusElement.textContent = online ? 'Online' : 'Offline';
statusElement.className = online ? 'online' : 'offline';
}
}
// Background Sync
async setupBackgroundSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
// Register background sync
try {
await registration.sync.register('sync-data');
console.log('Background sync registered');
} catch (error) {
console.error('Background sync registration failed:', error);
}
}
}
async setupPeriodicSync(registration) {
try {
await registration.periodicSync.register('data-update', {
minInterval: 24 * 60 * 60 * 1000 // 24 hours
});
console.log('Periodic sync registered');
} catch (error) {
console.error('Periodic sync failed:', error);
}
}
// Push Notifications
async setupPushNotifications() {
if ('Notification' in window && 'serviceWorker' in navigator) {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
this.subscribeToPush();
}
}
}
async subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
});
// Send subscription to backend
await fetch('/api/service-worker/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
}
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Data Management
async loadAppData() {
try {
const response = await fetch('/api/pwa/products');
const products = await response.json();
this.renderProducts(products);
} catch (error) {
console.error('Failed to load data:', error);
this.loadFromCache();
}
}
async syncOfflineData() {
// Sync any pending offline actions
try {
await fetch('/api/pwa/sync/now', { method: 'POST' });
console.log('Offline data synced');
} catch (error) {
console.error('Sync failed:', error);
}
}
// Cache Management
async loadFromCache() {
if ('caches' in window) {
const cache = await caches.open('pwa-cache-v1');
const response = await cache.match('/api/pwa/products');
if (response) {
const products = await response.json();
this.renderProducts(products);
}
}
}
renderProducts(products) {
// Render products in UI
const main = document.querySelector('main');
main.innerHTML = products.map(product => `
<div class="product">
<h3>${product.name}</h3>
<p>${product.description}</p>
</div>
`).join('');
}
// Offline Data Storage
async storeOfflineAction(action) {
if (!navigator.onLine) {
const actions = JSON.parse(localStorage.getItem('offlineActions') || '[]');
actions.push(action);
localStorage.setItem('offlineActions', JSON.stringify(actions));
}
}
}
// Initialize the app
new PWAApp();

6. Advanced PWA Features

Install Prompt Handling

@RestController
@RequestMapping("/api/install")
public class InstallPromptController {
@GetMapping("/criteria")
public ResponseEntity<InstallCriteria> getInstallCriteria() {
InstallCriteria criteria = new InstallCriteria();
criteria.setMinVisits(2);
criteria.setMinEngagementTime(30); // seconds
criteria.setRequiredFeatures(Arrays.asList("serviceWorker", "appInstalled"));
return ResponseEntity.ok(criteria);
}
@PostMapping("/track")
public ResponseEntity<?> trackInstallEvent(@RequestBody InstallEvent event) {
// Track installation metrics
metricsService.trackInstall(event);
return ResponseEntity.ok().build();
}
// DTO classes
public static class InstallCriteria {
private int minVisits;
private int minEngagementTime;
private List<String> requiredFeatures;
// Getters and setters
}
public static class InstallEvent {
private String eventType; // BEFORE_INSTALL_PROMPT, INSTALL_ACCEPTED, INSTALL_DISMISSED
private String userAgent;
private LocalDateTime timestamp;
// Getters and setters
}
}

Performance Monitoring

@Service
public class PwaMetricsService {
private final MetricRepository metricRepository;
public PwaMetricsService(MetricRepository metricRepository) {
this.metricRepository = metricRepository;
}
@Async
public void trackPerformanceMetric(PerformanceMetric metric) {
metricRepository.save(metric);
// Alert if performance degrades
if (metric.getLoadTime() > 3000) { // 3 seconds
alertSlowPerformance(metric);
}
}
@Async
public void trackCoreWebVitals(CoreWebVitals vitals) {
// Track Core Web Vitals
if (vitals.getLcp() > 2500) { // Largest Contentful Paint
alertPoorLCP(vitals);
}
if (vitals.getFid() > 100) { // First Input Delay
alertPoorFID(vitals);
}
if (vitals.getCls() > 0.1) { // Cumulative Layout Shift
alertPoorCLS(vitals);
}
}
public PerformanceReport generatePerformanceReport(LocalDate from, LocalDate to) {
List<PerformanceMetric> metrics = metricRepository.findByDateRange(from, to);
PerformanceReport report = new PerformanceReport();
report.setAverageLoadTime(calculateAverageLoadTime(metrics));
report.setP75LoadTime(calculatePercentileLoadTime(metrics, 75));
report.setP95LoadTime(calculatePercentileLoadTime(metrics, 95));
report.setTotalVisits(metrics.size());
return report;
}
private void alertSlowPerformance(PerformanceMetric metric) {
// Send alert (email, Slack, etc.)
System.out.println("Slow performance detected: " + metric.getLoadTime() + "ms");
}
// Similar methods for other alerts...
}
@Entity
@Table(name = "performance_metrics")
public class PerformanceMetric {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String pageUrl;
private Long loadTime; // in milliseconds
private Long firstContentfulPaint;
private Long largestContentfulPaint;
private Long firstInputDelay;
private Double cumulativeLayoutShift;
private String userAgent;
private String connectionType;
private LocalDateTime measuredAt;
// Constructors, getters, setters
}

7. Configuration and Deployment

Application Properties

# application.properties
server.port=8080
spring.web.resources.static-locations=classpath:/static/
# PWA Configuration
pwa.name=My Progressive Web App
pwa.short-name=MyPWA
pwa.theme-color=#000000
pwa.background-color=#ffffff
# VAPID Keys for Push Notifications
vapid.public.key=YOUR_VAPID_PUBLIC_KEY
vapid.private.key=YOUR_VAPID_PRIVATE_KEY
# Cache Configuration
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=3600s
# Database
spring.datasource.url=jdbc:h2:file:./pwa-db
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

Security Configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors().and()
.csrf().disable() // For API endpoints, consider CSRF configuration
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**", "/api/manifest/**", 
"/api/service-worker/**", "/", "/static/**").permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';")
)
);
return http.build();
}
}

This comprehensive PWA with Java backend setup provides:

  • Service worker integration for offline functionality
  • Push notifications with VAPID
  • Background sync capabilities
  • Performance monitoring
  • Offline data synchronization
  • Install prompt management
  • Comprehensive caching strategies

The architecture ensures your web app works reliably regardless of network conditions while providing a native-app-like experience.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper