Website Communication built with SharedWorker and iFrame in MPA
Loading...
Syome Back to Posts

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

SharedWorker Advantages

Detailed Comparison

To better understand the differences between Broadcast Channel and SharedWorker, let’s look at a detailed comparison:

FeatureBroadcast ChannelSharedWorker
Data PersistenceNo - data is lost when pages are closedYes - data persists in worker memory
Request CoordinationNo - each page must make its own requestYes - requests are automatically deduplicated
Lifecycle IndependenceTied to page lifecycleIndependent of page lifecycle
Communication PatternPeer-to-peer between pagesCentralized through worker
Data StorageNone - only message passingCan store data in memory
Error HandlingLimited - errors are page-specificCentralized error handling
Browser SupportModern browsersModern browsers
ComplexitySimple APIMore complex but more powerful
Use Case FitSimple message passingData 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);
  }
};

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:

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:

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:

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:

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:

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.