Build a Desktop App with PySide6 #6: Threads and Timers — Keeping the UI Responsive
Everything we have built so far finishes its work in an instant. Real apps are different: sooner or later a slow task shows up. You convert hundreds of files, fetch data over the network, or run a heavy computation. Wire a task like that directly to a button handler and the entire window stops responding until the work is done. This post explains why that happens at the structural level, and covers the two fixes: QThread and QTimer.
It runs in seven parts.
- #1 What PySide6 is — desktop apps with Qt and Python
- #2 Widgets and layouts — assembling the screen
- #3 Signals and slots — the core of event handling
- #4 Qt Designer and UI files — draw the screen, then load it
- #5 Model/View — connecting data to lists and tables
- #6 Threads and timers — keeping the UI responsive ← this post
- #7 Packaging and distribution — building executables with PyInstaller
Reproducing the problem — code that freezes the window #
Let us create the problem on purpose first. This window runs a 5-second task when you click the button.
import sys
import time
from PySide6.QtWidgets import (
QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget, QLabel,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = QLabel("Idle")
self.button = QPushButton("Start task")
self.button.clicked.connect(self.run_task)
layout = QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.button)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def run_task(self):
self.label.setText("Working...")
time.sleep(5) # simulate heavy work
self.label.setText("Done")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()Click the button and for five seconds you cannot move the window or close it. The label never shows “Working…” either — it jumps straight to “Done” after five seconds. The operating system marks the app as not responding.
The reason lies in the event loop we covered in #3. The event loop driven by app.exec() processes one event at a time. While run_task, the handler for the click event, does not return for five seconds, every repaint request and mouse event waits in the queue. The label text change is only painted on the next screen update, which is why “Working…” never appeared.
The rule — only the main thread touches the UI #
The fix is to move heavy work onto another thread. Before doing that, engrave Qt’s fundamental rule: widgets are created and modified on the main thread only. Calling self.label.setText(...) directly from a worker thread may appear to work at first, but it is code that crashes without warning.
So how does a worker thread get its results onto the screen? The answer is the signals we learned in #3. When a signal crosses a thread boundary, it is automatically queued and its slot runs on the main thread. The worker thread only emits signals; touching widgets stays with the slots on the main thread.
The canonical QThread pattern — Worker and moveToThread #
Examples that subclass QThread and override run are widespread, but the officially recommended shape is to put the work in a QObject (a Worker) and place it on a thread with moveToThread. Here is the complete code, including progress reporting.
import time
from PySide6.QtCore import QObject, Signal, Slot
class Worker(QObject):
progress = Signal(int) # progress (0-100)
finished = Signal(str) # completion message
failed = Signal(str) # error message
@Slot()
def run(self):
try:
for i in range(1, 101):
time.sleep(0.05) # real code: file processing, network, etc.
self.progress.emit(i)
self.finished.emit("Processed 100 items")
except Exception as e:
self.failed.emit(str(e))from PySide6.QtCore import QThread
from PySide6.QtWidgets import QProgressBar
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.progress_bar = QProgressBar()
self.button = QPushButton("Start task")
self.button.clicked.connect(self.start_task)
# ... layout code is the same as the previous example ...
def start_task(self):
self.button.setEnabled(False)
self.thread = QThread()
self.worker = Worker()
self.worker.moveToThread(self.thread)
# run Worker.run when the thread starts
self.thread.started.connect(self.worker.run)
# progress and results all arrive as signals
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.finished.connect(self.on_finished)
self.worker.failed.connect(self.on_failed)
# cleanup — stop the thread when the job ends, then release objects
self.worker.finished.connect(self.thread.quit)
self.worker.failed.connect(self.thread.quit)
self.thread.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.thread.start()
def on_finished(self, message: str):
self.statusBar().showMessage(message)
self.button.setEnabled(True)
def on_failed(self, error: str):
self.statusBar().showMessage(f"Failed: {error}")
self.button.setEnabled(True)Click the button and the window stays fully responsive while the progress bar climbs in real time. The structure comes down to three lines.
- The Worker only computes and emits its results as signals. It knows nothing about widgets.
- The main window connects those signals to slots and updates widgets on the main thread.
- Errors are also delivered through the
failedsignal instead of raising out of the thread. An exception raised inside a worker thread silently disappears if you leave it alone.
self.thread and self.worker are kept as instance attributes. If you make them local variables, Python’s garbage collection reclaims the objects as soon as the method returns, and the thread vanishes right as it starts. Always hold them as attributes so the references stay alive while the job runs.QTimer — periodic work and delayed calls #
Some light repetitive jobs do not need a thread at all: updating a clock every second, or clearing a message a few seconds later. QTimer fits here. Instead of creating a separate thread, QTimer registers a slot call on the event loop’s schedule, so as long as the slot finishes instantly it has no effect on the UI.
from PySide6.QtCore import QTimer, QTime
self.clock_label = QLabel()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_clock)
self.timer.start(1000) # 1000ms interval
def update_clock(self):
now = QTime.currentTime().toString("HH:mm:ss")
self.clock_label.setText(now)For a one-off delayed call, singleShot is the simplest form.
QTimer.singleShot(3000, self.statusBar().clearMessage)Remember that QTimer slots also run on the main thread. Put heavy work inside one and the UI freezes exactly like the first example — anything slow still belongs to a Worker thread.
Where to go next — QThreadPool and asyncio #
If you need to run several jobs in parallel, the QThreadPool + QRunnable combination beats creating threads by hand. The pool manages how many run at once, which suits multiple downloads or multiple file conversions. For this series we only note that it exists and what it is for.
It is also worth settling the relationship with Python’s asyncio. Qt’s event loop and asyncio’s event loop are separate worlds, so async def code cannot run in a PySide6 app as-is. Dedicated integrations exist (such as PySide6’s QtAsyncio module), but they are a topic of their own, so we leave them outside the scope of this series. The Worker pattern in this post covers the large majority of a desktop app’s asynchronous needs.
Wrapping up #
Three takeaways from this post.
- The event loop processes one event at a time, so a slow handler makes the entire window stop responding.
- Move heavy work into the Worker (QObject) + moveToThread pattern, and deliver results and errors to the main thread entirely through signals. Only the main thread touches widgets.
- Light periodic work and delayed calls are what QTimer is for — but QTimer slots also run on the main thread.
The next post, “Build a Desktop App with PySide6 #7: Packaging and Distribution — Building Executables with PyInstaller”, is the final part of the series. We will turn the app we built into a distributable that runs on computers without Python installed.