Redux-Saga race conditions

Szabolcs Damján
Byborg Engineering
Published in
5 min readNov 24, 2020

--

Photo by Jan Starek on Unsplash

Asynchronous program flow is a well known feature in JavaScript environments. All of us extensively use callbacks, Promises at the implementation level. These structures often grow to an unmanageable beast, known as callback-, and Promise hell.

Partly therefore a lot of projects choose the Redux-Saga Library to ease the handling of the asynchronous challenges.

Redux-Saga has introduced a way of multi-tasking. Multi-tasking in the sense, that you can start a lot of parallel tasks -which of them can be blocked at any time — without blocking the whole application program flow. This behavior was possible only by using web workers in JavaScript before the introduction of the so called “generator functions”, which can pause and resume their operation. These possibilities allow coders to flatten the formerly mentioned callback hell, or more generally, the nested asynchronous calls.

Multi-threading and multi-tasking are different technologies, but from the application point of view, both offer parallel execution of tasks. As I mentioned above, you can run tasks parallel in JavaScript using web workers, through it’s multi-threading from technical point of view. This would mean that:

  • The number of concurrent tasks is limited
  • Task are running on separate CPU threads
  • The application has no control over how exactly the execution of tasks is scheduled to each other

Using the generator functions based multi-tasking solutions, however, is different:

  • The number of tasks is unlimited
  • The tasks are running on single CPU thread
  • This is cooperative multi-tasking — meaning that the individual task can handle over the execution by using the “yield” keyword (however, this depends on the task runner/scheduler implementation)

Multi-tasking is truly a powerful tool in our hands, but we should be aware of its characteristics, both in the good and bad sense. It’s really obvious when multiple tasks are in place ( and operating ) at the same time, they will “race” for the resources. Depending on the implementation of the task runner environment ( in our particular case on Redux-Saga ), they will be paused at certain steps, and the next task will run for a while. Just to remark, this situation is just like accessing the database in a multi-user environment. In the multi-tasking world the shared state also exists. In our case, the shared data manifests in the Redux store.

Let us see a pretty obvious example to warmup, just so we get closer to the topic.

We all are accustomed to asynchronous operation in JavaScript. Regardless we use callbacks, Promises or the newer “async / await” syntax. These asynchronous operations often modify a shared state, so programmers usually pay attention to avoid the possible race conditions. Using the generator functions in Redux-Saga, we can flatten this async code structures, making the async calls less obvious, because these calls will look like sync ones.

In the example above, the program flow will wait for the result of the backend call, causing the task to pause. During this time, all other tasks keep running and possibly dispatch state modifications as well.

All the things up to this point are not exactly new, so go forward a little bit.

The “yield” keyword is used to pause the operation of the generator function and hand over the execution to another task, until it pauses on the upcoming yield instruction. By this theory we could expect that the task runner will pause the tasks at each “yield” expression and transfer the operation to the next task. Thinking in concurrency and race conditions we could assume that the shared state could be altered between two consecutive “yield” expressions by any another saga-task. This assumption forces us to pay extra attention to the shared state related operations. For example, all the read-modify-update like functions are really dangerous from the race-conditions point of view.

Keep in mind any state update related operation containing more than one “yield” counts to be a non-atomic one, holding the possibility of a race-condition!

Theory and practice sometimes differ…

To be honest, the task scheduling in Redux-Saga is a little bit different than the above mentioned, classical cooperative multi-tasking. Despite the experienced behavior, Saga tasks are not paused, and the control is not passed to the next task on each yield expressions. Sagas seem to run continuously until the next async effect (also a yield expression) like “delay” or promise resulting function calls. Following this track, we would think, that the code in our first example would run continuously until the “fetch backend data” async operation, but the reality seems to be different… again.

Some effects run continuously, like “select” and sometimes “call” too (when the called function returns a non-promise value), but what about “put” effect? We could assume it shares its behavior with the previously mentioned ones, and runs without stopping, but wait! It works differently. “Put” effects cause the task to stop, and the execution is handed over to the next task.

Let us check out the following lab experiment:

We have a simple reducer, containing only two fields and we will start two simple test tasks in parallel, reading and updating the central state.

And the result is the following. The expected values in the example assume that the “put” effects are not causing task pausing and execution handover.

The test results clearly show that the Redux-Saga task scheduler pauses the tasks at the “put” effects as well, handing the execution over to the next task. This means that we have the risk to experience “nice” unwanted bugs originating from this little-known behavior.

Conclusion

Multi-tasking needs extra attention of course, but a lot of race condition situations can be avoided by using

  • less concurrent tasks (e.g. use “takeEvery” only if necessary)
  • atomic state-update operations only

We have seen, that all of the “read-modify-update” like saga operations constructed from select and put effects are non-atomic state updates. Other tasks can alter the state in-between, causing hard to debug race conditions. A lot of problems come from that situation (for example, when a certain task selects a set of items from the store, prepares a new data set, then updates the store with a put effect). Sometimes another task can alter the state before the “put” effect; then, the data your task has prepared for “put” is already obsolete!

In a Redux environment, the “reducer” functions come to help because they are implemented as pure functions running on singe thread and they do not play in the multi-tasking court. Every state update executed by reducer functions counts to be an atomic operation, not conflicting with other tasks.

Feel free to experiment with the provided live example in code sandbox:

https://codesandbox.io/s/saga-concurrency-tcdhj?previewwindow=console

--

--