A cartoon developer multitasking while Blender renders jobs in the background

Keeping Blender Responsive: Non-Blocking Renders with bpy.app.timers

Post 7 of 10 — Blender Add-on Development with Python


In this series: We’re building a Batch Render Manager add-on from scratch. Post 1 covered the basics. Post 2 set up the package structure. Post 3 built the five core operators. Post 4 built the panel UI. Post 5 defined the data model. Post 6 built the queue runner. This post makes it non-blocking.


At the end of Post 6, we had a queue runner that worked. It launched renders as subprocesses, injected per-job overrides, and tracked status correctly. But the last thing we said about it was honest: it blocks the UI completely while each render runs. Queue a dozen scenes at production quality and your Blender session is locked for hours.

This post fixes that. By the end we’ll have:

  • Each subprocess launched without blocking, using subprocess.Popen()
  • A timer callback polling the running subprocess every two seconds
  • The progress bar updating in real time as jobs complete
  • Stop Queue actually terminating the running process
  • Start and Stop button poll() methods tied to real running state

The tool that makes all of this possible is bpy.app.timers.


Why execute() Can’t Run Long Tasks

Before we get into solutions, it’s worth being precise about the problem.

Blender’s Python environment is single-threaded from the perspective of the UI. When your operator’s execute() method runs, Blender hands control to your Python code and waits for it to return. While your code is running, Blender can’t redraw the interface, process mouse events, or respond to any user input. If execute() takes a hundred milliseconds, you get a small freeze. If it calls subprocess.run() and sits waiting for a render — which might take an hour — the application is locked for an hour.

The core constraint is that any code touching Blender’s data (reading queue state, updating job status, triggering a redraw) has to run on the main thread. That constraint shapes the entire approach.


Blender’s Answer: bpy.app.timers

bpy.app.timers is a simple but powerful system: you register a Python callable, and Blender calls it on the main thread on a regular schedule, between UI redraws. Your callable returns either a float (the number of seconds until the next call) or None to unregister itself.

import bpy

def my_callback():
    print("tick")
    return 1.0  # call again in 1 second

bpy.app.timers.register(my_callback, first_interval=1.0)
Python

Because the callback runs on the main thread between UI events, it can safely read and write Blender data — context.scene, properties, tags for redraw, everything. And because it returns control to Blender between each call, the UI stays responsive. The subprocess that’s actually rendering runs in a separate OS process and doesn’t touch Blender’s memory at all.

The signature of a timer callback is simple: no arguments, returns a float or None.


Why Not Python Threads?

You might wonder: why not run subprocess.run() in a threading.Thread? Let the render happen in the background, and update state when it finishes.

The problem is thread safety. Blender’s Python API is not thread-safe. Calling bpy.data or modifying properties from a background thread can cause crashes, corrupted state, or silent failures. It works sometimes, in some versions, for some operations — but it’s not reliable, and it fails in ways that are hard to diagnose.

bpy.app.timers is the right tool for this job. The timer callback runs on the main thread, so it can safely touch Blender data. The subprocess runs in a completely separate OS process, so it doesn’t interfere with Blender at all.


The New Architecture

Here’s how the pieces fit together after this post:

  1. BATCHRENDER_OT_start_queue.execute() finds the first pending job, launches it with subprocess.Popen() (non-blocking), marks the job as RUNNING, and registers a timer callback.
  2. The timer callback (_poll_render_timer) runs every two seconds. It checks whether the current subprocess has finished. If it’s done, it updates job status, finds the next pending job, launches it, and reschedules itself. If there are no more jobs, it cleans up and returns None to unregister.
  3. BATCHRENDER_OT_stop_queue.execute() terminates the running subprocess, resets the job status to PENDING, and removes the timer.

The timer is the heartbeat. The subprocess is the worker. The operator is the on/off switch.


Module-Level State

The timer callback is a plain function — not a method on an operator class. It has no self, no context, no persistent state of its own. We need somewhere to store the currently running subprocess and the index of the current job so the callback can find them on each tick.

We’ll use module-level variables in queue_runner.py:

_active_process = None   # The running subprocess.Popen instance
_active_job_index = None  # Index into context.scene.batch_render_jobs
Python

This is appropriate because there can only ever be one active render process at a time — we’re running jobs sequentially. These variables represent the single running state of the queue, not per-instance data. And since they live in a module that Blender imports once, they persist across operator calls for the lifetime of the add-on session.

We also need a way for the poll() methods in operators.py to check whether the queue is running. The right tool for that is a BoolProperty on bpy.types.Scene. It’s accessible from anywhere with a context, participates in Blender’s undo system, and can drive UI state directly.

We’ll add two new properties to properties.py:

# In properties.py register():
bpy.types.Scene.batch_render_is_running = bpy.props.BoolProperty(
    name="Queue Running",
    description="True while the batch render queue is active",
    default=False,
)

bpy.types.Scene.batch_render_jobs_done = bpy.props.IntProperty(
    name="Jobs Done",
    description="Number of completed jobs in the current run",
    default=0,
)
Python

And in unregister():

del bpy.types.Scene.batch_render_is_running
del bpy.types.Scene.batch_render_jobs_done
Python

batch_render_is_running is the single source of truth for “is the queue active right now.” batch_render_jobs_done lets us calculate a progress fraction for the progress bar — jobs done divided by total enabled jobs.


Updating queue_runner.py

We’ll keep build_python_expr() and build_command() exactly as they were — those are still correct. What changes is everything around how we launch and manage the running state.

# queue_runner.py
# Handles background rendering of queued .blend files.

import bpy
import subprocess
import os

# Module-level state for the currently active render.
_active_process = None
_active_job_index = None

def build_python_expr(job):
    """
    Build the --python-expr string to inject override settings
    into the subprocess Blender session before rendering.
    Returns None if no overrides are active.
    """
    lines = ["import bpy", "scene = bpy.data.scenes[0]"]
    has_overrides = False

    if job.override_frame_range:
        lines.append(f"scene.frame_start = {job.frame_start}")
        lines.append(f"scene.frame_end = {job.frame_end}")
        has_overrides = True

    if job.override_resolution:
        lines.append(f"scene.render.resolution_x = {job.res_x}")
        lines.append(f"scene.render.resolution_y = {job.res_y}")
        has_overrides = True

    if job.override_samples:
        lines.append(f"scene.cycles.samples = {job.samples}")
        lines.append(f"scene.eevee.taa_render_samples = {job.samples}")
        has_overrides = True

    if job.override_output:
        path = os.path.normpath(job.output_path)
        escaped = path.replace("\\", "\\\\")
        lines.append(f"scene.render.filepath = '{escaped}'")
        has_overrides = True

    if not has_overrides:
        return None

    return "; ".join(lines)

def build_command(job):
    """Build the full subprocess command list for a single render job."""
    blender_path = bpy.app.binary_path
    cmd = [blender_path, "--background", job.filepath]

    python_expr = build_python_expr(job)
    if python_expr:
        cmd.extend(["--python-expr", python_expr])

    cmd.extend([
        "--render-anim",
        "--frame-start", str(job.frame_start),
        "--frame-end", str(job.frame_end),
    ])

    return cmd

def _launch_job(scene, index):
    """
    Launch the subprocess for the job at the given index.
    Updates module-level state and sets the job status to RUNNING.
    """
    global _active_process, _active_job_index

    job = scene.batch_render_jobs[index]
    cmd = build_command(job)

    print(f"[Batch Render] Starting: {job.name}")
    print(f"[Batch Render] Command: {' '.join(cmd)}")

    _active_process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    _active_job_index = index
    job.status = 'RUNNING'

def _find_next_pending(scene, start_index):
    """
    Return the index of the next enabled PENDING job at or after start_index.
    Returns None if no such job exists.
    """
    jobs = scene.batch_render_jobs
    for i in range(start_index, len(jobs)):
        job = jobs[i]
        if job.enabled and job.status == 'PENDING':
            return i
    return None

def _finish_queue(scene):
    """Clear state and mark the scene as no longer running."""
    global _active_process, _active_job_index

    _active_process = None
    _active_job_index = None
    scene.batch_render_is_running = False
    print("[Batch Render] Queue complete.")

def _poll_render_timer():
    """
    Timer callback — runs on the main thread every 2 seconds.

    Checks whether the active subprocess has finished. If it has,
    updates job status and launches the next pending job (or finishes
    the queue). Returns 2.0 to reschedule, or None to unregister.
    """
    global _active_process, _active_job_index

    scene = bpy.context.scene
    if _active_process is None or scene is None:
        return None

    # poll() returns None while running, or a return code when done.
    returncode = _active_process.poll()

    if returncode is None:
        # Still running — tag the viewport for redraw and check again later.
        for area in bpy.context.screen.areas:
            if area.type == 'VIEW_3D':
                area.tag_redraw()
        return 2.0

    # The process has finished.
    job = scene.batch_render_jobs[_active_job_index]

    if returncode == 0:
        job.status = 'DONE'
        print(f"[Batch Render] Done: {job.name}")
    else:
        job.status = 'FAILED'
        _, stderr = _active_process.communicate()
        print(f"[Batch Render] FAILED: {job.name}")
        if stderr:
            print(stderr.decode(errors='replace'))

    scene.batch_render_jobs_done += 1

    # Find and launch the next job.
    next_index = _find_next_pending(scene, _active_job_index + 1)

    if next_index is not None:
        next_job = scene.batch_render_jobs[next_index]
        if not os.path.isfile(next_job.filepath):
            next_job.status = 'FAILED'
            print(f"[Batch Render] ERROR: File not found: {next_job.filepath}")
            scene.batch_render_jobs_done += 1
            next_index = _find_next_pending(scene, next_index + 1)

    if next_index is not None:
        _launch_job(scene, next_index)
        return 2.0
    else:
        _finish_queue(scene)
        return None  # Unregister the timer.

def start_queue(context):
    """
    Entry point called by BATCHRENDER_OT_start_queue.
    Validates the first job, launches it, and registers the polling timer.
    """
    scene = context.scene
    scene.batch_render_jobs_done = 0
    scene.batch_render_is_running = True

    first_index = _find_next_pending(scene, 0)
    if first_index is None:
        scene.batch_render_is_running = False
        return

    job = scene.batch_render_jobs[first_index]
    if not os.path.isfile(job.filepath):
        job.status = 'FAILED'
        print(f"[Batch Render] ERROR: File not found: {job.filepath}")
        scene.batch_render_is_running = False
        return

    _launch_job(scene, first_index)

    if not bpy.app.timers.is_registered(_poll_render_timer):
        bpy.app.timers.register(_poll_render_timer, first_interval=2.0)

def stop_queue(context):
    """
    Entry point called by BATCHRENDER_OT_stop_queue.
    Terminates the running subprocess and cleans up.
    """
    scene = context.scene

    if _active_process is not None:
        _active_process.terminate()
        try:
            _active_process.wait(timeout=5)
        except subprocess.TimeoutExpired:
            _active_process.kill()

        if _active_job_index is not None:
            job = scene.batch_render_jobs[_active_job_index]
            job.status = 'PENDING'
            print(f"[Batch Render] Stopped: {job.name}")

    if bpy.app.timers.is_registered(_poll_render_timer):
        bpy.app.timers.unregister(_poll_render_timer)

    _finish_queue(scene)
Python

Let’s walk through the key decisions.


subprocess.Popen() vs. subprocess.run()

subprocess.run() launches a process and blocks until it’s done. subprocess.Popen() launches a process and returns immediately, giving you a Popen object you can check on later. That’s the entire difference, and it’s the one that matters here.

To check whether a Popen process has finished, you call process.poll(). It returns None if the process is still running, or the integer return code if it’s finished. Zero means success; anything else means something went wrong. The timer callback does this on every tick.

One thing worth knowing: when using Popen with stdout=PIPE and stderr=PIPE, the output is buffered in memory until you read it. For a long render producing a lot of output, this buffer could theoretically fill up and stall the subprocess. Blender’s --background output is modest in practice, so this isn’t a real concern here — but it’s good to be aware of if you adapt this pattern for other use cases.


The Timer Callback

_poll_render_timer() does one thing: check whether the current render is done and act accordingly. The leading underscore signals it’s an internal function not meant to be called directly from outside the module.

The return value drives scheduling: return 2.0 means “call me again in two seconds,” return None means “I’m done, remove me.” Two seconds is a reasonable polling interval — fast enough to catch when a short render finishes promptly, slow enough that the overhead of calling this function is completely negligible.

The callback reads bpy.context.scene rather than taking a context argument. Timer callbacks are bare callables — they receive no arguments. bpy.context is the module-level context object, which gives access to the active scene. This works correctly here because we only interact with scene data, not with specific editor contexts.

The area.tag_redraw() call in the “still running” branch tells Blender to redraw the 3D viewport on the next frame. Without this, the progress bar in our panel won’t update during a render — Blender only redraws when something triggers it, and a timer callback on its own isn’t enough.


Stopping Cleanly

stop_queue() calls _active_process.terminate(), which sends SIGTERM on macOS and Linux, or calls TerminateProcess on Windows. This is a polite request to stop. We then call process.wait(timeout=5) to give the subprocess up to five seconds to exit gracefully before escalating to process.kill() — forced termination.

When a job is stopped mid-render, we reset its status back to PENDING rather than FAILED. Stopping a render isn’t a failure — the user might just be pausing to adjust settings. They should be able to restart the same job without manually resetting its status.


Updating operators.py

With run_queue() replaced by start_queue() and stop_queue(), we need to update the operators to match and update the poll() methods to use the new batch_render_is_running property.

# Updated BATCHRENDER_OT_start_queue
class BATCHRENDER_OT_start_queue(bpy.types.Operator):
    """Start processing the render queue"""
    bl_idname = "batch_render.start_queue"
    bl_label = "Start Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        if context.scene.batch_render_is_running:
            return False
        jobs = context.scene.batch_render_jobs
        return any(j.status == 'PENDING' and j.enabled for j in jobs)

    def execute(self, context):
        queue_runner.start_queue(context)
        return {'FINISHED'}

# Updated BATCHRENDER_OT_stop_queue
class BATCHRENDER_OT_stop_queue(bpy.types.Operator):
    """Stop the currently running render queue"""
    bl_idname = "batch_render.stop_queue"
    bl_label = "Stop Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        return context.scene.batch_render_is_running

    def invoke(self, context, event):
        return context.window_manager.invoke_confirm(self, event)

    def execute(self, context):
        queue_runner.stop_queue(context)
        return {'FINISHED'}
Python

The start_queue poll now gates on two conditions: the queue isn’t already running, and there’s at least one enabled pending job. The stop_queue poll is simply whether the queue is running. Previously these always returned True (or checked for RUNNING status but never found it). Now they reflect real state, and the buttons will be enabled and disabled at exactly the right times.


Wiring the Progress Bar

Back in Post 4, we drew a placeholder progress bar with a hardcoded factor=0.0. Now we have the data to make it real. Update _draw_progress() in panels.py:

def _draw_progress(self, layout, context):
    scene = context.scene
    jobs = scene.batch_render_jobs

    is_running = scene.batch_render_is_running
    total = sum(1 for j in jobs if j.enabled)
    done = scene.batch_render_jobs_done

    if is_running:
        factor = done / total if total > 0 else 0.0
        label = f"Rendering job {done + 1} of {total}..."
        status_text = "Status: Running"
        status_icon = 'PLAY'
    elif any(j.status == 'FAILED' for j in jobs):
        factor = 1.0
        label = "Finished with errors"
        status_text = "Status: Failed"
        status_icon = 'ERROR'
    elif total > 0 and all(j.status == 'DONE' for j in jobs if j.enabled):
        factor = 1.0
        label = "All jobs complete"
        status_text = "Status: Done"
        status_icon = 'CHECKMARK'
    else:
        factor = 0.0
        label = "No active render"
        status_text = "Status: Idle"
        status_icon = 'INFO'

    col = layout.column(align=True)
    col.label(text=status_text, icon=status_icon)
    col.progress(factor=factor, text=label)
Python

The progress fraction is jobs_done / total_enabled. During a run, batch_render_jobs_done increments as each job finishes. After the last job, batch_render_is_running goes false and the bar shows its final state based on whether any jobs failed.


Cleaning Up on Add-on Unload

There’s one edge case to handle: what happens if someone disables the add-on while a render is in progress?

When Blender calls unregister(), the timer is still registered and the subprocess is still running. If the timer fires after unregister() has run, it’ll try to access Blender data that may no longer be valid. We need to clean up proactively.

Update unregister() in __init__.py:

def unregister():
    # Stop any active queue before unregistering.
    from . import queue_runner
    if bpy.app.timers.is_registered(queue_runner._poll_render_timer):
        bpy.app.timers.unregister(queue_runner._poll_render_timer)
    if queue_runner._active_process is not None:
        queue_runner._active_process.terminate()

    preferences.unregister()
    panels.unregister()
    operators.unregister()
    properties.unregister()
Python

This runs before the rest of unregistration, while all modules are still valid. If a render is in progress, we terminate it and clear the timer before tearing anything else down.


A Note on Modal Operators

You may have noticed we’re not using a traditional modal operator here — just bpy.app.timers. What’s a modal operator, and when would you use one instead?

A modal operator uses a modal() method that Blender calls each time a relevant event occurs — mouse moves, timer ticks, keyboard input. The operator stays alive between events by returning {'RUNNING_MODAL'}. Modal operators are the right choice when your task needs to respond to user input while it’s running: a brush tool tracking mouse position, an interactive placement operator, a transform that previews in real time.

Here’s the minimal shape, for reference:

class EXAMPLE_OT_modal(bpy.types.Operator):
    bl_idname = "example.modal"
    bl_label = "Modal Example"

    def invoke(self, context, event):
        # Set up a timer so modal() gets called on a regular schedule.
        self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
        context.window_manager.modal_handler_add(self)
        return {'RUNNING_MODAL'}

    def modal(self, context, event):
        if event.type == 'TIMER':
            # Do work on each tick.
            pass

        if self._done:
            self.cancel(context)
            return {'FINISHED'}

        return {'RUNNING_MODAL'}

    def cancel(self, context):
        context.window_manager.event_timer_remove(self._timer)
Python

invoke() sets up a timer and registers the operator to receive events. modal() receives those events and returns {'RUNNING_MODAL'} to stay alive or {'FINISHED'} when done. cancel() cleans up the timer.

For the Batch Render Manager, bpy.app.timers gives us what we need with less ceremony. Our queue runs entirely independently — there’s no user input to respond to mid-render, just a subprocess to poll. Timers are simpler, they don’t push anything onto the modal handler stack, and they keep working correctly even if the user switches editors or changes context while renders are running.


Testing It

Reload the add-on. Set up one or two jobs with real .blend files and a short frame range — a single frame keeps the test quick.

Click Start Queue. Blender should not freeze. The Stop Queue button should become available immediately. When the render finishes, the job status should change to Done and the progress bar should update.

One thing worth knowing: the progress bar tracks jobs completed, not the progress of the currently running job. If you have three jobs queued, the bar moves in thirds — one jump per finished job. There’s no way to see how far along the current render is (e.g. frame 12 of 50). That’s a real limitation and an honest source of frustration on long renders, but it’s inherent to how we’re running Blender as a subprocess. Addressing it would require either parsing Blender’s stdout or a different architecture entirely — something to consider for a future iteration.

Try clicking Stop Queue mid-render. After the confirmation dialog, the running job should reset to Pending and the Stop button should gray out.

To verify the unload cleanup, start a render and then immediately disable the add-on from Preferences. It should terminate the subprocess and unregister the timer without errors.


What We Have Now

After seven posts, the Batch Render Manager’s core functionality is complete:

  • Feature 1 (multi-.blend queue) is fully implemented and non-blocking
  • Feature 2 (per-item overrides) has been working since Post 6
  • The queue runs in the background without freezing Blender
  • The progress bar reflects real state (per job completed, not per-frame progress within a job)
  • Start and Stop buttons are correctly gated on queue state
  • Cleanup is handled on add-on unload

The remaining features are notifications (Post 8) and error handling and recovery (Post 9), before we wrap everything up with packaging in Post 10.


What’s Coming Next

In Post 8, we’ll build out AddonPreferences — Blender’s mechanism for add-on-wide settings that live outside any .blend file. We’ll add fields for SMTP email, Discord webhook, and desktop notification settings, and implement the actual notification dispatch so you get an alert when your queue finishes (or hits an error). We’ll also cover JSON queue persistence, so a queue you’ve set up survives a Blender restart.

See you there.


Have questions or ran into a snag? Drop a comment below. And if you want work-in-progress updates, upcoming tutorials, and early looks at what I’m building, subscribe to the newsletter.

Visited 18 times, 1 visit(s) today

Leave a Reply

Your email address will not be published. Required fields are marked *