I’ve published a few examples of this in other projects (for example Implementing a client side progress bar reporting on server side work for htmlService) but I got a request from someone on LinkedIn to help explain how in principle to do this via a very simple example – this article will walk throughhow to do this in detail

The problem

HtmlService runs client side in the browser, and initiates server side functions via the google.script.run function. An important concept to grasp is that each server side script invocation is a completely new instantiation and doesn’t inherit anything from previous invocations. That means that there are no persistent global variables or any other technique that can help, so we need a different approach.

Here’s a server side simple script that simply sleeps for various times. What we want to do here is have a client side script continually reporting on the progress of the server side script. Later on I’ll make this a more interesting script.

function main() {


  // "starting"
  Utilities.sleep(2000);

  // "step 2"
  Utilities.sleep(2000);

  // "step 3"
  Utilities.sleep(2000);

  // "step 4"
  Utilities.sleep(2000);

  // "step 5"
  Utilities.sleep(2000);
  
  // done

}
various steps

Approach

There’s no way to update the client side directly from the server side, however we can control the server side from the client side. We can use the CacheService to periodically update progress on server side, and we can interrogate cache from the client side by invoking a different script, to see how far the target script has come along.

We’ll also need a unique id shared between client and server to use to reference the same cache entry as each other. I’m also going to use the user cache rather than the script cache. This will ensure disambiguation between different instances of the same script.

Running simultaneous server scripts (even the same script multiple times) is possible because each clent side call to google.script.run provokes a completely seperate instance of the script, and it’s this simultaneity that enables a script to query the status of another via the CacheService

Server side

Let’s enhance the server side script to write its progress to cache.

// deploy the html service app
const doGet = () =>  HtmlService.createHtmlOutputFromFile('index');

/**
 * we'll use usercache for disambiguation
 * @return {Cache} the cache to use
 */
const getCache = () => CacheService.getScriptCache()

/**
 * write progress to cache
 * @param {string} id a unique id for the cache entry
 * @param {string} value a value to write
 * @return {void} 
 */
const setProgress = (cacheId, value) => getCache().put(cacheId, value);

/**
 * get progress from cach
 * @param {string} id a unique id for the cache entry
 * @return {string} the cache entry
 */
const getProgress = (cacheId) => getCache().get(cacheId);

/**
 * the worker script
 * @param {string} cacheId id a unique id for the cache entry
 */
const main = (cacheId) => {

  setProgress (cacheId, "starting");
  Utilities.sleep(2000);
  
  setProgress(cacheId, "step 2");
  Utilities.sleep(2000);
 
  setProgress(cacheId, "step 3");
  Utilities.sleep(2000);

 
  setProgress(cacheId, "step 4");
  Utilities.sleep(2000);
 
  setProgress(cacheId, "step 5"); 
  Utilities.sleep(2000);
   
  setProgress(cacheId, "done")
}
setting progress in cache

Index.html

A minimal html file with just an element to receive messages from the server side

<!DOCTYPE html>
<html>

<head>
  <base target="_top">
</head>

<body>
  <div id="output">not started yet</div>

  <script>
	// ... TODO client side script
  </script>

</body>

</html>
index.html

Client side script

I’m going to modernize google.script.run a little so we can use promises. This is a simplified version of the technique discussed in Apps Script V8: Provoke server side code from add-ons and htmlservice

    // a general promisified script.run
    const runner = (name,...args) => new Promise(
      (resolve, reject) => 
        google.script.run
          .withFailureHandler((err) => reject(err))
          .withSuccessHandler((result) => resolve(result))[name](...args));
Reusable promisified google.script.run

We’ll be polling the server side occassionally, and could use setInterval. However, this is quite a blunt instrument as it will continue to poll server side even when there’s slowness in the previous response. I prefer to poll using an interval since the last response. That way we won’t overwhelm the server side and can react better to errors.

setTimeout

First of all we’ll need a promisified version of setTimeout

    // promisify setTimeout
    const waiter = (delayMs) => new Promise(resolve=> setTimeout (resolve , delayMs));
promisified version of settimeout

We’ll also need a function to know when its finished server side. We could do this by detecting when the main script has completed, but the progress reporting might be just a subset of that main script, so instead we’ll send a definitive signal from server side.

    // how to know if we're finished
    const isFinished = (data) => data === 'done';
is it finished?

Recursive polling

To make the polling more responsive, we’ll issue a repoll some time after the previous poll successfully finished.

    // this is how long to wait between polls
    const POLLING_INTERVAL_MS = 500;

    // this is the progress checker
    const progress = () => runner("getProgress", cacheId)
      .then((data) => {
        // display it
        document.getElementById("output").innerHTML = data;
        // go again
        if (!isFinished(data)) waiter(POLLING_INTERVAL_MS).then(()=>progress())
      })
      .catch(err => console.log(err))
Recursive polling

Kicking off the main script and initiating polling

All that’s left is to kick off the main server side script, and start polling from the client side.

    // this kicks everything off
    runner("main", cacheId)
      .then(() => {
        console.log("main finished server side")
      })
      .catch(err => console.log(err))
    
    // start polling
    progress();
kicking it all off

All together

Here’s the entire index.html, now with the script constructed.

<!DOCTYPE html>
<html>

<head>
  <base target="_top">
</head>

<body>
  <div id="output">not started yet</div>

  <script>
    // promisify setTimeout
    const waiter = (delayMs) => new Promise(resolve=> setTimeout (resolve , delayMs));
    
    // a general promisified script.run
    const runner = (name,...args) => new Promise(
      (resolve, reject) => 
        google.script.run
          .withFailureHandler((err) => reject(err))
          .withSuccessHandler((result) => resolve(result))[name](...args));
    
    
    // use a unique value to distinguish multiple runs
    // we use the usercache to distinguish multiple users running the same thing
    const cacheId = new Date().getTime().toString(32) + '_poller';
        
    // how to know if we're finished
    const isFinished = (data) => data === 'done';
    
    // this is how long to wait between polls
    const POLLING_INTERVAL_MS = 500;

    // this is the progress checker
    const progress = () => runner("getProgress", cacheId)
      .then((data) => {
        // display it
        document.getElementById("output").innerHTML = data;
        // go again
        if (!isFinished(data)) waiter(POLLING_INTERVAL_MS).then(()=>progress())
      })
      .catch(err => console.log(err))

    // this kicks everything off
    runner("main", cacheId)
      .then(() => {
        console.log("main finished server side")
      })
      .catch(err => console.log(err))
    
    // start polling
    progress();


  </script>

</body>

</html>
all together

More useful version

In real life you’ll want to decorate the rendering of progress perhaps with a progress bar or something, and of course the server side script will be doing something useful. Let’s generalize the server side a little more.

Cache status payload

We’ll probably want to pass more than just a string – so we’ll update the get and set progress functions to handle objects if it detected them.

/**
 * write progress to cache
 * @param {string} id a unique id for the cache entry
 * @param {string | object} value a value to write
 * @return {void} 
 */
const betterSetProgress = (cacheId, value) => getCache().put(cacheId, typeof value === 'object' ? JSON.stringify(value) : value.toString());

/**
 * get progress from cach
 * @param {string} id a unique id for the cache entry
 * @return {string | object } the cache entry
 */
const betterGetProgress = (cacheId) => {
  const value = getCache().get(cacheId);
  try {
    return JSON.parse(value)
  } 
  catch {
    return value
  }
}
handle objects in cache

Main script

Again we’re just simulating tasks, but this time we’ll make each take a random amount of time, and write additional information about progress to cache. Using this will allow the client side to provide more info about progress.

/**
 * the worker script
 * @param {string} cacheId id a unique id for the cache entry
 */
const betterMain = (cacheId) => {

  // make a list of false tasks lasting some random amount of time
  const tasks = Array.from({length: 10}).map(f=>Math.ceil(Math.random()* 5000))
  const startedAt = new Date().getTime();
  
  // this time we'll send an object like this
  // {startedAt: timestamp, timeNow: timeStamp, totalTasks: number , taskNumber}
  const progressPack = ({taskNumber = -1, done = false} = {}) => ({
    startedAt,
    timeNow: new Date().getTime(),
    totalTasks: tasks.length,
    taskNumber,
    done
  })
  
  // mark that we're starting
  betterSetProgress (cacheId,progressPack ());
  
  // wait various amounts of time then mark as done
  tasks.forEach ((delay, taskNumber)=> {
    Utilities.sleep(delay);
    betterSetProgress (cacheId,progressPack ({taskNumber}));
  })
    // mark that we're done
  betterSetProgress (cacheId,progressPack ({taskNumber: tasks.length , done: true}));
}
some random tasks

Improved index.html

The only real difference is that now we’ll be receiving an object with progress status rather than a string as before, so we need to do some formatting.

<!DOCTYPE html>
<html>

<head>
  <base target="_top">
</head>

<body>
  <div id="output">not started yet</div>

  <script>
    // promisify setTimeout
    const waiter = (delayMs) => new Promise(resolve=> setTimeout (resolve , delayMs));
    
    // a general promisified script.run
    const runner = (name,...args) => new Promise(
      (resolve, reject) => 
        google.script.run
          .withFailureHandler((err) => reject(err))
          .withSuccessHandler((result) => resolve(result))[name](...args));
    
    
    // use a unique value to distinguish multiple runs
    // we use the usercache to distinguish multiple users running the same thing
    const cacheId = new Date().getTime().toString(32) + '_poller';
        
    // how to know if we're finished
    const isFinished = (data) => data.done;
    
    // this is how long to wait between polls
    const POLLING_INTERVAL_MS = 500;

    // do some kind of formatting of result
    const formatPack = ({startedAt, timeNow , taskNumber, totalTasks, done }) => {
      const pct = taskNumber < 0 ? "0" : (taskNumber < totalTasks ? Math.round((taskNumber+1)/totalTasks * 100) : "100");
      const elapsed = timeNow - startedAt;
      return done ? `Completed ${totalTasks} in ${elapsed}ms` : `task ${taskNumber+1}/${totalTasks} after ${elapsed}ms`; 
    }

    // this is the progress checker
    const progress = () => runner("betterGetProgress", cacheId)
      .then((data) => {
        if (data) {
          document.getElementById("output").innerHTML = formatPack (data)
        }
        // go again
        // we have to pass on null data, as we have polled before cache was written
        if (!data || !isFinished(data)) waiter(POLLING_INTERVAL_MS).then(()=>progress())
      })
      .catch(err => console.log(err))

    // this kicks everything off
    runner("betterMain", cacheId)
      .then(() => {
        console.log("main finished server side")
      })
      .catch(err => console.log(err))
    
    // start polling
    progress();


  </script>

</body>

</html>
betterindex.html

 

Summary

You’ll want to decorate the progress report, perhaps using some of the other examples on this site. In any case, there should be some useful examples of modernizing polling and google.script.run here to use in other projects.

Links

Script: IDE

Github: https://github.com/brucemcpherson/bmPollingDemo

Apps Script V8: Provoke server side code from add-ons and htmlservice

Using promises to orchestrate Html service polling

Implementing a client side progress bar reporting on server side work for htmlService

Organizing parallel streams of server calls with google.script.run promises