On Building Software
Old School Workarounds: Keeping the Screen Up to Date
Years ago, it was common assumption that when a page loaded, nothing about it would change until you manually refreshed the page.
That’s not the case with SaaS products anymore. Today, if you’re on a dashboard, list screen, or detail page, when someone has updated something on the screen you’re looking at, you expect to see the update automatically, even if you let a page sit quietly in a separate tab that you bounce back to hours later.
DoneDone is an old app (15 years and running!) built in a time before this expectation was ubiquitous. And though we released an entirely new version in 2018, the infrastructure started as a copy-over from the original version.
So, when it came time to add auto-updating, I wanted to do it in the least invasive way possible. Here’s my approach.
Note: DoneDone has two main features: Projects (where tasks are tracked) and mailboxes (where email conversations are tracked). For the purposes of this post, they’re nearly identical concepts, so I’ll just talk about the projects side of DoneDone.
Where auto-updates should happen
I wanted to have three key areas of the app auto-update whenever new information is available.
(1) The Dashboard
The dashboard shows your projects as cards. I wanted the “last updated” times, a count of active tickets, and a count of tickets assigned to you auto-updated whenever they changed.
(2) Project landing pages
Within a project’s landing page, I wanted tasks to auto-update whenever a meaningful change was made to any one of them. DoneDone projects have both a listing view and kanban board view, but ostensibly they show the same information.
(3) Task detail pages
I wanted the individual details to auto-update whenever a meaningful update was made to them.
The Approach
Here’s the approach I used to create the auto-update behavior starting with the data we need to detect updates.
Storing the last updated dates in the database
The linchpin to the approach starts in the relational database. I track a lastUpdated
date field on the tables whose records I care to know got updated.
In my case, it’s just two tables:
ActionableItems
—All task tickets are stored in this table and have a related foreign key to their specific project in theProjects
table.Projects
—All base information for projects (like their name and access level) are stored in this table.
Theoretically, I don’t need the lastUpdated
column on Projects
because in practice, this column only updates when a task within a project is updated (At the current moment, I don’t consider events like changing the name of a project or adding members to it as an update I care to broadcast to idle screens).
This means I could always find the last updated date for a project by finding out which task has the most recent updated date. Something like SELECT TOP(1) LastUpdated FROM ActionableItems WHERE RelatedProjectID = @project_id_in_question ORDER BY LastUpdated DESC
.
But, I’m worried about performance here (even with an index on RelatedProjectID
), especially as our ActionableItems
table grows into the tens or hundreds of millions of records. So, I’d rather track the lastUpdated
value for a project in its own column directly on Projects
.
In code, whenever any updates happen to a task, DoneDone will also update the lastUpdated
column to the current moment (now
) for that ticket’s ActionableItems
record in the same transaction.
Similarly, when any updates happen to a task, I update the lastUpdated
field for its project once the task update has transacted.
This sets all the data I need in the database:
- For any given task, I can find out when it was last updated.
- For any given project, I can find out when the last task update was made.
Pulling back the current timestamp when an updatable screen renders
When one of these three areas (the dashboard, the list views, or the task detail) is accessed, the app makes a few calls to the DoneDone API to hydrate the components of the screen with the data it needs.
I happen to use Vue.js on the front end so calls are generally executed on Vue’s created()
event callback method of the main components’ lifecycle.
In each case, one of these API calls (namely, the one that’s pulling back data related to tasks) also passes back a numeric property called accessEpochTime
. This is just the current date/time converted to UNIX epoch time in milliseconds.
Since the data related to tasks is pulled on the same request as the accessEpochTime
value, I can be certain (to a degree of accuracy I’m willing to accept) that nothing else has changed to the data as of the accessEpochTime
timestamp.
The value of accessEpochTime
is stored on the Vue component side. It’s what I use to compare against to determine if the screen’s data has gotten stale.
Pinging the API for timestamp updates
For each of these three screens, at the end of the created()
method, I leverage trusty old setInterval()
to make periodic calls to the DoneDone API to see if updates to tasks exist. I happen to set the interval calls to every 19 seconds.
Now I could simply call the same API methods on these intervals as I do on the created()
method. But the majority of those interval calls would just return the exact same data each time. DoneDone isn’t an app where transactions to a specific account are happening every second of every day. So, I’d like to minimize calling those heftier queries whenever I can.
Instead, these intervals each call an API method that only return the relevant last updated date (returned, again, as UNIX epoch time in milliseconds).
- The task detail screen’s
setInterval
calls the API for the last update on the specific task in question. - The project listing/kanban board screens’
setInterval
calls the API for the last update on the specific project in question. - The dashboard screens’
setInterval
calls the API for the most recent last update amongst all projects the user belongs to.
Each of these calls is lightweight.
The task detail call is grabbing the lastUpdated
value from the database by filtering directly against its task’s primary key (id
). Fast!
The project listing and kanban board calls are similarly grabbing the lastUpdated
value from the database by filtering directly against its project’s primary key (id
). (Hence why I decided to add the extra column in the first place).
The dashboard’s call is also lightweight—on the backend I’m just selecting the TOP(1)
value of all lastUpdated
project values filtered from a list of the user’s accessible project IDs.
Refetching the data if necessary
Once the call is completed, I check whether the stored accessEpochTime
is less than the lastUpdated
value returned. If it is, I can assume a data update has been made and then make that heavier data call immediately.
The one exception is with the task detail. Rather than automatically refresh the task’s information on the page, I surface a little “updated” notification on the lower-right corner of the page and only refresh when clicked. This avoids the scenario where you’re reading a long comment only to see it update magically before your eyes.
In all scenarios, I then set accessEpochTime
on the client to the newly fetched value for the future interval calls.
Finally, when a user leaves the screen, I use clearInterval
to ensure pings aren’t continually being made. In Vue, I detect this through beforeDestroy()
.
Things I like and things I can live with
As with any technical implementation, there are imperfections (and also happy little accidents).
One nice side effect of this strategy is that if I open multiple tabs to the same screen, each tab will refresh on its own time—because each one is pulling back its own unique accessEpochTime
to start.
There’s also a gotcha though. For the project listing and dashboard scenarios, it’s very possible that even though there had been an update, the data refresh wouldn’t show anything new.
For instance, it could be that the project listing had a filter against just tasks with high priority and instead, an update was made to a task of medium priority. On the dashboard, it could be that none of the tasks related to the user in question were updated, so their counts would remain the same. In both scenarios, we’d be fetching new data and have nothing to show for it.
I’m OK with this false positive. There’s no inherent harm in reloading the same data (save wasting data transfer bytes). And, as mentioned earlier, transactions aren’t occurring at a blazing rate.
So, I still get most of the benefit of saving data transfer on the big reads even if occasionally the reads being made aren’t necessary.
The alternative would be to have a more granular fabric of lastUpdated
scenarios—the permutations of which would grow pretty fast. For another app, this might be worth it, just not this one.
I’m a stickler for simple “old school” solutions. But getting to the best version of simple is always about weighing trade-offs. And trade-offs are not only app-specific, but can often be a very subjective thing.
If you liked this one, here’s another I wrote recently on interstitial states:
Thanks for reading!