I played with your example and (as usual these days it seems) I have not been able to solve the problem, but I’ve been able kinda isolate the problem further. I broke up your functions into even more granular components, changed the function to alter the Div’s visibility instead of the text property, and added a CustomJS to a) see if it’s a classic case of explicitly triggering change.emit(), and/or b) see when the changes to the visibility property are “felt” on the JS side:
from bokeh.models import Div, Button, CustomJS
from bokeh.events import DocumentReady
from bokeh.layouts import column
from bokeh.io import curdoc
from bokeh.document import Document
import time
spinner_text = """
<!-- https://www.w3schools.com/howto/howto_css_loader.asp -->
<div class="loader">
<style scoped>
.loader {
border: 16px solid #f3f3f3; /* Light grey */
border-top: 16px solid #3498db; /* Blue */
border-radius: 50%;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</div>
"""
loadData = Button(width = 200, label="Load Data", button_type="success")
# div_spinner = Div(text="",width=120,height=120)
div_spinner = Div(text=spinner_text,width=120,height=120,visible=False)
layout = column(div_spinner, loadData)
cb = CustomJS(args=dict(div_spinner=div_spinner)
,code='''
console.log('cb triggered!')
div_spinner.change.emit()''')
div_spinner.js_on_change('visible',cb)
def dataLoader():
print('loading')
# div_spinner.text = spinner_text
div_spinner.visible = True
def doingStuff():
print('doing stuff')
for i in range(10000):
print(i)
def hideSpinner():
print('Done')
# div_spinner.text = ""
div_spinner.visible=False
loadData.on_click(dataLoader)
loadData.on_click(doingStuff)
loadData.on_click(hideSpinner)
# curdoc().on_event('document_ready', hideSpinner)
curdoc().add_root(layout)

It seems to me that do_stuff is being executed first, then both the load and hide spinner functions actually happen, hence the loader “blinking” right as the counter finishes.
I don’t know why the order of operations is occurring like this → I feel like you’re tantalizingly close.
EDIT: Ha, the frame rate on my gif only managed to capture only one “blink” of the loader (i click three times in the gif), but trust me, it blinks every time you click the button.