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.