Website Communication built with SharedWorker and iFrame in MPA
An experience of a team project as a frontend engineer
Basic Knowledge
Frontend after all, here we use JavaScript to unlock almost the power of the browser. If you have ever made a website, you may know what is SPA (single page application) and MPA (multi page application). As a common website, many developers will choose MPA, which has a better structure of code and better performance for browser. But there is a trade-off when your team adopts frontend and backend separation.
When you need to persist and share data across multiple pages, you might consider using
localStorage
. However, this approach presents security vulnerabilities, particularly exposure to XSS (Cross-Site Scripting) attacks.
Alternatively, fetching data from the server before each page load is an option, but this creates performance issues due to excessive server requests and increased latency.
How to Handle These Problems?
So, the first idea came out of my mind is to use broadcast Channel
- one page fetch from the server, and other pages can listen to the message then update user’s data. However, here comes the problem again.
Broadcast Channel requires broadcaster and receivers all exist on the browser. That is, if there are too many pages of your website, it becomes difficult to coordinate which page should make the request and how to ensure all pages receive the data. Additionally, Broadcast Channel does not provide persistent data storage - when a page is refreshed or closed, the data will be lost.
In contrast, SharedWorker provides a more robust solution. It runs in a separate thread that is independent of individual pages, allowing it to persist data in memory even as users navigate between pages. This means that user data can be fetched once and shared across all pages without re-requesting from the server.
Detailed Comparison
Broadcast Channel Limitations
-
No Persistent Storage: Broadcast Channel only facilitates message passing between pages but does not store data. Each page must independently manage its own data state.
-
No Request Coordination: If multiple pages need the same data, each page using Broadcast Channel would need to make its own request to the server, resulting in redundant API calls.
-
Data Loss on Page Refresh: When a page is refreshed or closed, any data stored in that page’s JavaScript context is lost. New pages must either request data again or wait for another page to broadcast it.
-
Complex Implementation for Simple Tasks: To achieve the same functionality as SharedWorker, complex coordination logic would be needed to determine which page should make the request and how to distribute the data to other pages.
SharedWorker Advantages
-
Persistent Data Storage: SharedWorker runs in its own thread, separate from individual pages. It can store data in its memory space, making it persistent across page navigation.
-
Automatic Request Deduplication: As shown in our implementation, the SharedWorker only makes one request to the server regardless of how many pages request the data. Subsequent requests are served from the cached data in the worker.
-
Independent Lifecycle: The SharedWorker is not tied to any specific page’s lifecycle. It continues running as long as there are connected ports, ensuring data availability.
-
Centralized Data Management: All data fetching and caching logic is centralized in one place, making it easier to maintain and debug.
Detailed Comparison
To better understand the differences between Broadcast Channel and SharedWorker, let’s look at a detailed comparison:
Feature | Broadcast Channel | SharedWorker |
---|---|---|
Data Persistence | No - data is lost when pages are closed | Yes - data persists in worker memory |
Request Coordination | No - each page must make its own request | Yes - requests are automatically deduplicated |
Lifecycle Independence | Tied to page lifecycle | Independent of page lifecycle |
Communication Pattern | Peer-to-peer between pages | Centralized through worker |
Data Storage | None - only message passing | Can store data in memory |
Error Handling | Limited - errors are page-specific | Centralized error handling |
Browser Support | Modern browsers | Modern browsers |
Complexity | Simple API | More complex but more powerful |
Use Case Fit | Simple message passing | Data persistence and coordination |
Implementation Examples
First of all, we need a main worker to manage the shared data.
// worker.js - Shared data management
let cachedData = null;
let isLoading = false;
let pendingRequests = [];
onconnect = function(e) {
const port = e.ports[0];
port.start(); // Explicitly start the port
port.onmessage = async function(event) {
if (event.data.type === 'get_user_data') {
// If we already have data, send it immediately
if (cachedData) {
port.postMessage({ type: 'user_data_result', data: cachedData });
return;
}
// If we're already loading, queue this request
if (isLoading) {
pendingRequests.push(port);
return;
}
// Otherwise, start loading
isLoading = true;
try {
const response = await fetch('/api/user-data');
cachedData = await response.json();
isLoading = false;
// Send data to the requesting port
port.postMessage({ type: 'user_data_result', data: cachedData });
// Send data to all pending requests
pendingRequests.forEach(pendingPort => {
pendingPort.postMessage({ type: 'user_data_result', data: cachedData });
});
// Clear pending requests
pendingRequests = [];
} catch (error) {
isLoading = false;
port.postMessage({ type: 'user_data_error', error: error.message });
// Send error to all pending requests
pendingRequests.forEach(pendingPort => {
pendingPort.postMessage({ type: 'user_data_error', error: error.message });
});
// Clear pending requests
pendingRequests = [];
}
}
};
// Handle port disconnection
port.onclose = function() {
// Remove this port from pendingRequests if it's there
const index = pendingRequests.indexOf(port);
if (index !== -1) {
pendingRequests.splice(index, 1);
}
};
};
For each page that needs the data, you can use the following code to fetch.
// page.js - In each page that needs the data
const worker = new SharedWorker('/path/to/worker.js');
const port = worker.port;
port.start();
// Request data
port.postMessage({ type: 'get_user_data' });
// Handle response
port.onmessage = function(event) {
if (event.data.type === 'user_data_result') {
// Process data
const userData = event.data.data;
// Update UI with user data
} else if (event.data.type === 'user_data_error') {
// Handle error
console.error('Error fetching data:', event.data.error);
}
};
Navigation Approaches and Their Impact on Data Persistence
Standard Multi-Page Navigation
When using standard browser navigation (clicking links that lead to new pages or opening pages in new tabs), SharedWorker can still function properly when multiple pages from the same origin are open simultaneously. Each page connects to the same SharedWorker instance, and data can be shared between them.
However, this approach has drawbacks:
- User Experience: Opening many tabs or windows can be cumbersome and clutter the user’s browser
- Resource Usage: Each tab consumes additional memory and processing power
- Context Switching: Users may find it difficult to manage multiple tabs
Direct Page Navigation and SharedWorker Disconnection
An alternative approach is to navigate directly within the current page (replacing the current page content rather than opening new tabs). While this provides a cleaner user experience, it has a significant drawback:
When a page navigates to a new URL, the current page is unloaded and a new page is loaded. This process causes the SharedWorker connection to be severed because:
- The JavaScript context of the current page is destroyed
- All active connections, including SharedWorker ports, are closed
- The new page must establish a fresh connection to the SharedWorker
Example of navigation that breaks SharedWorker connection
javascript:
window.location.href = 'new-page.html';
html:
<a href="new-page.html"></a>
iframe-Based Navigation Solution
Avoid the use of iframe’s to display the full content if your page would like to gain SEO flow just like this post said. For more methods, please check out this post written by me.
To maintain the benefits of single-page navigation while preserving SharedWorker connections, an iframe-based approach can be used:
- The main page establishes and maintains the SharedWorker connection
- Navigation occurs within an iframe, keeping the main page (and its connections) intact
- All iframe content can still access the SharedWorker through the persistent connection in the parent page
First of all, we need to build a bridge between the main page and the iframe.
// SessionBridge.js
export class SessionBridge {
static sharedPort = null;
static cachedData = null;
static isInitialized = false;
/**
* Initialize the SharedWorker connection
*/
static init() {
if (this.isInitialized) return;
try {
// Create the SharedWorker connection
const worker = new SharedWorker('/path/to/worker.js');
this.sharedPort = worker.port;
this.sharedPort.start();
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize SharedWorker:', error);
}
}
/**
* Request data from the SharedWorker
* @param {string} dataType - Type of data to request
* @returns {Promise<any>} - Promise that resolves with the requested data
*/
static requestData(dataType) {
// Return cached data if available
if (this.cachedData && this.cachedData[dataType]) {
return Promise.resolve(this.cachedData[dataType]);
}
// Initialize if not already done
if (!this.isInitialized) {
this.init();
}
// Return a promise that will be resolved when data is received
return new Promise((resolve, reject) => {
// Create a unique handler for this request
const messageHandler = (event) => {
if (event.data.type === `${dataType}_result`) {
// Cache the data
if (!this.cachedData) this.cachedData = {};
this.cachedData[dataType] = event.data.data;
// Clean up listener
this.sharedPort.removeEventListener('message', messageHandler);
// Resolve the promise
resolve(event.data.data);
} else if (event.data.type === `${dataType}_error`) {
// Clean up listener
this.sharedPort.removeEventListener('message', messageHandler);
// Reject the promise
reject(new Error(event.data.error));
}
};
// Attach message handler
this.sharedPort.addEventListener('message', messageHandler);
// Request the data
this.sharedPort.postMessage({ type: `get_${dataType}` });
});
}
/**
* Reset the cache, if needed
*/
static resetCache() {
this.cachedData = null;
}
}
Then use it commonly in other js modules.
// Usage in a page
// Initialize the bridge
import { SessionBridge } from '/path/to/SessionBridge.js';
SessionBridge.init(); // Or it isn't required to call
// Request data
SessionBridge.requestData('user_data')
.then(userData => {
// Update UI with user data
console.log('User data received:', userData);
})
.catch(error => {
// Handle error
console.error('Failed to fetch user data:', error);
});
This SessionBridge implementation provides:
- A clean API for connecting to SharedWorker
- Automatic initialization management
- Promise-based data requests
- Client-side caching to reduce redundant data processing
And Don’t forget the main character to solve the issue - using iframe.
<!-- Parent page containing the iframe -->
<!DOCTYPE html>
<html>
<head>
<title>Main Application</title>
</head>
<body>
<!-- Establish the bridge in the parent page -->
<script type="module">
import { SessionBridge } from "/path/to/SessionBridge.js";
// Just initialize the SharedWorker connection in the parent page
SessionBridge.init();
</script>
<!-- iframe for navigation -->
<iframe src="your-page.html"></iframe>
</body>
</html>
This approach ensures that:
- The SharedWorker connection is established in the parent page, which persists across iframe navigation
- All iframe content pages connect to the same SharedWorker instance
- Data fetched by any page is available to all other pages
- The connection survives navigation events within the iframe
- Users experience a clean, single-window navigation without losing data connections
Notice
It’s important to note that SharedWorker does not natively support ES6 module syntax. When creating a SharedWorker, the worker file must be written in traditional JavaScript syntax rather than ES6 modules. This means you cannot use import
/export
statements directly in your SharedWorker file. This is why I separate worker.js
and SessionBridge.js
.
This limitation is something to keep in mind when designing your data sharing architecture.
Conclusion
While Broadcast Channel is useful for simple message passing between pages, it does not provide the persistent data storage and request coordination needed for efficient data sharing across multiple pages. SharedWorker offers a more robust solution for sharing data across multiple pages in an MPA while minimizing server requests and maintaining security.
The SessionBridge pattern provides a clean abstraction layer that simplifies working with SharedWorker connections, making it easier to manage data requests and handle errors consistently.
The iframe-based navigation approach provides a solution to maintain SharedWorker connections during navigation, offering both a clean user experience and persistent data connections.
August 9, 2025 updated: Unless absolutely necessary, never use iframe to display the full content. Another approach to solve the issue of single-page navigation is here.