How Do Zephyr Work Queues (k_work) Handle Deferred Processing?
Last updated 24 July 2026 · 10 min read
Direct Answer
Zephyr's k_work is a deferred-work item: a function pointer submitted to a workqueue (a dedicated thread that runs items one at a time, in submission order) rather than executed immediately in the caller's context. k_work_submit() queues an item for the next available run; k_work_schedule() (on a delayable work item, k_work_delayable) adds a timer delay before submission. The built-in system workqueue is available by default for short, well-behaved handlers; anything that blocks, runs long, or must not compete with other subsystems' deferred work belongs on a dedicated workqueue created with k_work_queue_start(). The core use case is ISR-to-thread handoff: an interrupt handler cannot call blocking APIs or do meaningful processing, so it submits a k_work item and returns immediately, and the workqueue thread does the real work later at thread priority.
Detailed Explanation
An interrupt handler in Zephyr, like in any RTOS, has to be short. It cannot block, it cannot call an API that might sleep, and the longer it runs, the longer every lower-priority interrupt and thread on the system waits behind it. But the work an interrupt triggers (parsing a received packet, running a sensor conversion, writing to flash) is often exactly the kind of thing that takes real time and may need to block. Zephyr's answer to that mismatch is the workqueue: a dedicated thread whose entire job is to pull queued work items off a FIFO and run them one at a time, in ordinary thread context, at a priority you choose.
This is functionally the same problem FreeRTOS solves with a queue plus a processing task, or with its own daemon-task-based timer/pending-function-call mechanism (see How Do FreeRTOS Queues, Semaphores, and Mutexes Work? for that side of the comparison). Zephyr packages the pattern as a first-class kernel object, k_work, with the queue, the thread, and the submission API all built in, so you don't have to hand-roll the queue-plus-processing-task plumbing yourself.
For the broader picture of what Zephyr adds on top of a FreeRTOS-style kernel (device tree, Kconfig, and the full primitive set), see What Is Zephyr RTOS, and How Is It Different from FreeRTOS?
The Work Item: k_work
A k_work item wraps a single function pointer plus the bookkeeping needed to track its state (idle, queued, running). Define one with K_WORK_DEFINE() at file scope, or initialise it at runtime with k_work_init():
#include <zephyr/kernel.h>
static void sensor_process_fn(struct k_work *work)
{
/* Runs on the workqueue thread; blocking calls are fine here */
int32_t reading;
read_sensor(&reading);
publish_reading(reading);
}
K_WORK_DEFINE(sensor_work, sensor_process_fn);
Submitting the item queues it for the next available run:
void sensor_data_ready_isr(const struct device *dev, void *user_data)
{
/* ISR context: do the minimum possible, then hand off */
k_work_submit(&sensor_work);
}
k_work_submit() is interrupt-safe: it can be called from an ISR or from thread context with no separate FromISR variant to remember, which is a meaningful difference from FreeRTOS's convention of a distinct xQueueSendFromISR()/xSemaphoreGiveFromISR() for every ISR-context call. If the same work item is submitted again while it is already queued (but not yet running), Zephyr coalesces the request: the item runs once, not twice, which matters when a burst of interrupts should collapse into a single processing pass rather than queuing redundant work.
The System Workqueue
Zephyr provides a built-in system workqueue, a single thread started automatically at boot (when CONFIG_SYSTEM_WORKQUEUE_PRIORITY and related Kconfig options are at their defaults) that many kernel subsystems and drivers already use internally. k_work_submit() targets the system workqueue by default. It's the right choice for:
- Short handlers that don't block for long.
- Code with no specific priority requirement relative to other deferred work in the system.
- Getting something working quickly during bring-up, before workqueue architecture is a real design decision.
It's the wrong choice for anything that blocks for a meaningful duration (flash writes, network I/O, anything waiting on another thread) or that has strict latency requirements, because every subsystem's deferred work shares that one thread and one priority. A slow item queued ahead of yours delays everything behind it, including work items submitted by Zephyr's own drivers and subsystems, not just application code.
Dedicated Workqueues
For anything beyond simple, short deferred handlers, create a dedicated workqueue with its own thread, stack, and priority:
#define MY_WQ_STACK_SIZE 1024
#define MY_WQ_PRIORITY 5
K_THREAD_STACK_DEFINE(my_wq_stack_area, MY_WQ_STACK_SIZE);
static struct k_work_q my_work_q;
void app_init(void)
{
k_work_queue_start(&my_work_q, my_wq_stack_area,
K_THREAD_STACK_SIZEOF(my_wq_stack_area),
MY_WQ_PRIORITY, NULL);
k_thread_name_set(&my_work_q.thread, "my_wq");
}
Submit to a specific queue with k_work_submit_to_queue() instead of the plain k_work_submit(), which always targets the system workqueue:
k_work_submit_to_queue(&my_work_q, &sensor_work);
A dedicated workqueue gives you an independent priority (so it can run above or below the system workqueue and other application threads as the design requires), an independent stack (so a large local buffer or deep call chain in one handler cannot exhaust the system workqueue's shared stack), and isolation from every other subsystem's deferred work. The cost is one more thread's worth of RAM (stack) and one more scheduling entity to reason about. For a small, low-rate handler that isn't latency-sensitive, that cost usually isn't worth it, and the system workqueue is the better default.
Delayable Work: k_work_schedule and k_work_reschedule
A plain k_work item runs as soon as the workqueue reaches it; there is no way to delay submission. For that, use k_work_delayable, which pairs a work item with a kernel timer:
static void button_debounce_fn(struct k_work *work)
{
/* Runs after the delay elapses, on the workqueue thread */
if (button_is_still_pressed()) {
handle_button_press();
}
}
K_WORK_DELAYABLE_DEFINE(debounce_work, button_debounce_fn);
void button_isr(const struct device *dev, struct gpio_callback *cb, uint32_t pins)
{
/* (Re)start a 50 ms debounce window each time the ISR fires */
k_work_reschedule(&debounce_work, K_MSEC(50));
}
k_work_schedule() submits the item after the given delay, doing nothing if a submission is already pending. k_work_reschedule() cancels any pending delay and restarts it, the pattern used above for debouncing, where each new interrupt should push the deadline back rather than queue a second, earlier submission. Both accept K_NO_WAIT for immediate submission, K_MSEC()/K_SECONDS() for a fixed delay, or K_FOREVER to leave the item scheduled but never automatically submitted (useful when you want to arm the timer separately from initialising it).
Delayable work is the Zephyr equivalent of a FreeRTOS software timer used to defer or debounce a callback (see the timer callback pattern in How Do You Create and Schedule Tasks in FreeRTOS? for the FreeRTOS-side comparison), except that in Zephyr the deferred callback and the timer are the same object rather than a timer callback that separately signals a task.
The ISR-to-Thread Handoff Pattern
The pattern above (button_isr() calling k_work_reschedule()) is the general shape of ISR-to-thread handoff in Zephyr:
- The ISR does the minimum hardware-mandated work: read a status register, clear an interrupt flag, capture a timestamp or a small data value into a variable the work handler can read.
- The ISR submits (or reschedules) a
k_workitem and returns. - The workqueue thread runs the handler later, at thread priority, where blocking calls, longer processing, and calls into driver APIs that require thread context are all safe.
This mirrors ISR-to-task handoff in any RTOS: see What Are Interrupts in Embedded Systems? for interrupt handling fundamentals that apply regardless of RTOS choice, and FreeRTOS queues, semaphores, and mutexes for the equivalent FreeRTOS ISR-safe primitives (queues, binary semaphores, task notifications). The difference in Zephyr is that k_work bundles the "queue the deferred work" and "run it later on a dedicated thread" halves of that pattern into one object, rather than requiring you to wire a queue or semaphore to a hand-written processing task yourself.
Design Considerations
- Default to the system workqueue; move to a dedicated queue only when you have a reason. Reasons include: the handler blocks for a non-trivial duration, it has a genuine priority requirement relative to other deferred work, or it needs a larger stack than the system workqueue provides. Creating a dedicated workqueue for every small handler adds RAM and scheduling overhead without benefit.
- Size dedicated workqueue stacks from real high-water-mark data, the same discipline as any Zephyr thread: a work handler that calls into a driver, does floating-point math, or has deep call chains can need substantially more stack than a simple ISR-context handler would.
- Choose k_work vs k_work_delayable based on whether "as soon as possible" or "after a delay" describes the requirement. Debounce, retry-with-backoff, and periodic polling patterns call for
k_work_delayable; immediate ISR-to-thread handoff calls for plaink_work. - Remember that resubmitting a pending k_work item coalesces, but resubmitting a running one queues a second run. If a work item is currently executing when
k_work_submit()is called again, Zephyr queues another run once the current one completes; the coalescing behaviour only applies to an item that is queued but not yet started. Design handlers to tolerate being invoked more often than the triggering event count if this distinction matters for correctness. - A workqueue thread is still a thread: priority interacts with everything else in the system. A dedicated workqueue set to a very high priority can starve lower-priority threads exactly like any other high-priority thread would; there's nothing special about workqueue threads that exempts them from normal scheduling considerations.
For designs where ISR-to-thread handoff timing and workqueue priority directly affect real-time behaviour (sensor sampling pipelines, BLE connection event handling, or motor control loops), Zeus Design's firmware team designs and validates Zephyr RTOS architecture, including workqueue and thread priority allocation, for products built on the nRF Connect SDK and other Zephyr-based platforms.
See How Do You Set Up the nRF Connect SDK and Zephyr RTOS for nRF52 Development? for the project setup this pattern assumes, and nRF52 PPI/DPPI Peripheral Interconnect for a hardware-level alternative to ISR handoff on Nordic silicon: chaining peripheral events directly in hardware without CPU or workqueue involvement at all, for the cases where even a short ISR-to-thread delay is too slow.
Common Mistakes
- Calling blocking APIs from an ISR instead of deferring to a work item. This is the single most common Zephyr bring-up mistake for engineers new to the framework: calling a driver function, a logging call that can block, or any API documented as thread-only directly from an interrupt handler. Zephyr does not always fault immediately; the failure can surface later as a stack overflow, a corrupted kernel data structure, or an assertion in an unrelated part of the code, all of which cost more time to trace back to the real cause than the original blocking call would have taken to defer.
- Putting long-running work on the system workqueue without realising it blocks other subsystems. Since the system workqueue is shared by kernel code, drivers, and application code alike, a single slow handler queued on it delays every other deferred operation in the system, including ones with no visible connection to the slow handler. Symptoms show up as unrelated latency elsewhere, not as an obvious fault in the slow handler itself.
- Confusing k_work_submit() with k_work_schedule() and getting unexpected immediate execution.
k_work_schedule()on ak_work_delayableitem is required to get a delay; submitting the underlyingk_workdirectly (or callingk_work_submit()on a delayable item's embedded work field incorrectly) bypasses the delay entirely. - Not sizing the dedicated workqueue's stack for its actual worst-case call chain. A workqueue stack sized only for the smallest handler assigned to it overflows silently the first time a larger or more deeply nested handler runs on the same queue. The same stack-sizing discipline that applies to any Zephyr thread applies to workqueue stacks.
- Assuming k_work items run in ISR context because they originate from an ISR. Once a work item is queued, it always runs in the workqueue thread's context, at thread priority, with interrupts enabled normally. Code inside a work handler does not need, and should not use, ISR-safe API variants; it should use the same blocking-capable APIs any other thread would use.
Frequently Asked Questions
- What is the difference between k_work and k_work_delayable?
- k_work is submitted for immediate processing on the next available workqueue slot. k_work_delayable wraps a k_work item with a kernel timer, so k_work_schedule() (or k_work_reschedule() to replace a pending delay) submits the item only after a specified delay has elapsed. Use k_work_delayable for debounce timers, periodic polling, or any deferred action that should not run immediately — a plain k_work item has no delay mechanism of its own.
- Can I call k_work_submit() from an ISR?
- Yes, and this is one of its main purposes. k_work_submit() and k_work_schedule() are both interrupt-safe and are the recommended way to move processing out of an interrupt handler and onto a thread. Unlike some FreeRTOS primitives, there is no separate FromISR variant to remember — the same function is safe to call from interrupt or thread context, though it still must not be called with interrupts locked in a way that violates Zephyr's general ISR restrictions (no blocking calls, minimal execution time).
- Does the system workqueue run at a fixed priority?
- Yes, its priority is configured at build time via CONFIG_SYSTEM_WORKQUEUE_PRIORITY, which defaults to a low (numerically high, since Zephyr priority numbering is inverted) cooperative or preemptible priority depending on configuration — check your board's default in the generated .config. Because every subsystem that uses the system workqueue shares that single priority and that single thread, a latency-sensitive handler should not assume it will run promptly if other system-workqueue items are queued ahead of it; use a dedicated workqueue at an appropriate priority instead.
References
Related Questions
What Is Zephyr RTOS, and How Is It Different from FreeRTOS?
Zephyr RTOS explained: the device tree hardware model, Kconfig build configuration, kernel threading primitives, and how it differs from FreeRTOS.
What Is an RTOS (Real-Time Operating System)?
An RTOS is a lightweight operating system that gives embedded firmware deterministic task scheduling. Learn how RTOSes work and when you actually need one.
How Do FreeRTOS Queues, Semaphores, and Mutexes Work?
How to use FreeRTOS queues, semaphores, and mutexes for inter-task communication — including ISR-safe variants, task notifications, and event groups.
How Do You Create and Schedule Tasks in FreeRTOS?
Learn how to create FreeRTOS tasks with xTaskCreate, configure task priorities, size stacks safely, and start the scheduler on ARM Cortex-M MCUs.
How Do You Set Up the nRF Connect SDK and Zephyr RTOS for nRF52 Development?
Set up nRF Connect SDK (NCS) for nRF52 development: install tools, create a west workspace, configure Kconfig, build a Zephyr project, and flash with J-Link.
How Does PPI/DPPI Work on the nRF52 and nRF53?
How PPI and DPPI let nRF52/nRF53 peripherals trigger each other directly in hardware, with no CPU involvement — channels, fork vs publish/subscribe, and code.