Redux Thunk vs Redux Saga - what is the difference?
Redux Thunk vs Redux Saga - Thunk lets action creators return functions that call dispatch after async work completes; Saga uses generator functions that yield effect objects, and the middleware interprets and executes them.
Theory
TL;DR
- Thunk: action creator returns a function, that function calls
dispatchafter async work - Saga: generator function yields effect descriptors (
call,put,take), middleware does the actual work - Analogy: Thunk is a vending machine button, press it and get a result later. Saga is a factory supervisor, assigns tasks, monitors each step, redirects on failure
- Decision: single API call or basic CRUD? Thunk. Cancellation, polling, race conditions, debounce? Saga
- Bundle gap: Thunk ~1KB, Saga ~20KB with runtime overhead
Quick example
Both handle the same FETCH_USER_REQUEST action:
// Thunk - action creator returns a function, not a plain object
const fetchUser = (id) => (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
fetch(`/api/user/${id}`)
.then(res => res.json())
.then(user => dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }))
.catch(err => dispatch({ type: 'FETCH_USER_FAILURE', payload: err }));
};
// dispatch(fetchUser(123)) - thunk middleware detects a function and calls it// Saga - generator yields effects for middleware to execute
import { call, put, takeEvery } from 'redux-saga/effects';
function* fetchUserSaga(action) {
try {
const user = yield call(fetch, `/api/user/${action.payload}`);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
yield put({ type: 'FETCH_USER_FAILURE', payload: error });
}
}
// takeEvery('FETCH_USER_REQUEST', fetchUserSaga) - listens and runs on each actionWith Thunk, the function runs directly when dispatched. With Saga, call(fetch, ...) is a plain JavaScript object. The middleware reads it, calls fetch, then resumes the generator with the resolved value.
Key difference
Thunk middleware checks typeof action === 'function'. If true, it calls action(dispatch, getState) synchronously and returns the result. That is the entire Thunk implementation, under 10 lines of code. Saga runs generators via next() in a loop, treating each yielded object as an instruction: call means "run this async function", put means "dispatch this action", take means "wait for this action type". Because those instructions are plain objects, you can test Saga logic by checking what was yielded, with no mocking required.
When to use
- Single API call, no coordination between requests: Thunk, fewer files, no generator syntax to learn
- Debounce on a search input: Saga has a built-in
debounceeffect, Thunk needs an external utility or a manual timer - Multiple parallel requests: Saga's
all([...])handles this cleanly - Race conditions (first API wins, cancel the other): Saga's
race({...})does it in 3 lines - Cancellation when a user navigates away mid-request: Saga's
cancel()is built in, Thunk needsAbortControllerwired manually - Polling (check a server every N seconds): Saga's
while(true)withdelay()pattern, Thunk gets messy - App with fewer than 10 async actions: Thunk, avoid the overhead
Comparison table
| Feature | Redux Thunk | Redux Saga |
|---|---|---|
| Core mechanism | Dispatched function runs async imperatively | Generator yields effects, middleware executes |
| Async handling | async/await or promises inside thunk | yield call() for APIs, yield put() for dispatch |
| Control flow | Linear, manual try/catch | Built-in fork, join, race, cancel, retry |
| Testing | Mock dispatch and getState | Pure iterator checks, no mocks needed |
| Bundle size | ~1KB | ~20KB + runtime |
| Learning curve | 5 minutes | 1-2 hours (generators + effect model) |
| Debounce/throttle | External library or manual timer | Built-in debounce and throttle effects |
| When to use | Simple CRUD, basic data fetching | Real-time apps, complex async orchestration |
How the middleware handles this
Thunk intercepts store.dispatch(action). If typeof action === 'function', it calls action(dispatch, getState) synchronously and returns whatever the function returns. That is essentially the full source code of Redux Thunk.
Saga middleware starts the root saga generator and drives it with a next(resolvedValue) loop. When the generator yields call(fn, args), the middleware calls fn(...args), waits for the promise to settle, then resumes the generator with the result. If the promise rejects, it throws into the generator at the yield point. put(action) queues a dispatch. take(pattern) suspends the generator until a matching action arrives. This step-by-step execution is what makes Saga possible to pause, cancel, and test without mocking.
Common mistakes
1. Thunk: skipping getState for dependent logic
// Wrong - fetches without checking the cache
const fetchUser = (id) => async (dispatch) => {
const user = await api.get(id);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
};
// Right - avoid duplicate requests
const fetchUser = (id) => async (dispatch, getState) => {
if (getState().users[id]) return; // already in store
const user = await api.get(id);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
};2. Saga: calling the API directly without yield call()
// Wrong - synchronous call, api.getUser returns a Promise object, not resolved data
function* fetchUserSaga(action) {
const user = api.getUser(action.payload); // not yielded
yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); // user is a Promise, not data
}
// Right
function* fetchUserSaga(action) {
const user = yield call(api.getUser, action.payload);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
}3. Saga: takeEvery that triggers on its own dispatches
// Wrong - if the handler dispatches the same action type, it re-triggers itself
function* watchFetch() {
yield takeEvery('FETCH_DATA', handleFetch);
}
// Right - takeLatest cancels the previous task on each new action
function* watchFetch() {
yield takeLatest('FETCH_DATA', handleFetch);
}4. Thunk: no cleanup after component unmount
// Wrong - dispatches to the store after the component is gone
const fetchUser = (id) => async (dispatch) => {
const user = await api.get(id);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); // React warning if unmounted
};
// Right - AbortController signals the fetch to stop
const fetchUser = (id, signal) => async (dispatch) => {
try {
const user = await api.get(id, { signal });
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (err) {
if (err.name !== 'AbortError') dispatch({ type: 'FETCH_USER_FAILURE', payload: err });
}
};
// In component useEffect: const ctrl = new AbortController();
// dispatch(fetchUser(id, ctrl.signal)); return () => ctrl.abort();Real-world usage
- Create React App and Redux Toolkit ship with Thunk configured by default
- Stream Chat SDK uses Saga for WebSocket orchestration and reconnection logic
- Next.js apps typically use Thunk for SSR data fetching (
getServerSideProps), Saga for WebSocket or polling flows - Electron desktop apps use Saga to manage file I/O races, like canceling a file read when a tab closes
- In most production React apps I've reviewed, teams start with Thunk and bolt on Saga only when a specific flow gets complicated enough to justify the overhead
Follow-up questions
Q: How does Saga handle promise rejections inside call()?
A: When the promise from call() rejects, Saga throws the error into the generator at the yield point. A try/catch around yield call(...) catches it, the same way async/await does.
Q: Can you cancel a running Thunk?
A: Not natively. You add AbortController manually and call controller.abort(), usually in a useEffect cleanup. Saga's cancel() and takeLatest handle cancellation without extra wiring.
Q: What is the performance difference for 100 concurrent requests?
A: Thunk creates 100 independent functions in flight with no coordination. Saga forks lightweight tasks with lower memory per task, but generators carry their own overhead. For most apps the difference is negligible, but Saga's structured concurrency prevents runaway tasks.
Q: What is the difference between fork and spawn in Saga?
A: fork creates a task attached to the parent - if the child throws, the error propagates up. spawn creates a detached task - if it throws, the parent saga continues unaffected. Use spawn for fire-and-forget background work.
Q: (Senior) How does select() interact with a blocking take() effect?
A: select() is non-blocking - it reads the current store state immediately and resumes the generator in the same tick. take(pattern) suspends the generator until a matching action arrives. If you write yield take('X'); yield select(...), the select reads state after X has already been processed by reducers. Thunk's getState() is also synchronous, but since it runs inside a plain function rather than a paused generator, the timing semantics differ - there is no equivalent of "waiting for a specific action" without external coordination.
Q: What breaks when migrating a Thunk app to Saga?
A: Components that do await dispatch(thunkAction()) and rely on the returned promise. Saga dispatches do not return values, so that pattern stops working. You need to restructure around action listening, callback channels, or eventChannel.
Examples
Basic: fetch user data on request action
// Thunk - fetchUser returns a function, not a plain action object
const fetchUser = (id) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (err) {
dispatch({ type: 'FETCH_USER_FAILURE', payload: err.message });
}
};
dispatch(fetchUser(42)); // thunk middleware intercepts and calls the inner function// Saga - generator handles the same flow declaratively
import { call, put, takeLatest } from 'redux-saga/effects';
function* fetchUserSaga({ payload: id }) {
try {
const res = yield call(fetch, `/api/users/${id}`);
const user = yield call([res, 'json']); // call a method on an object
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (err) {
yield put({ type: 'FETCH_USER_FAILURE', payload: err.message });
}
}
export function* watchUsers() {
yield takeLatest('FETCH_USER_REQUEST', fetchUserSaga);
// takeLatest cancels the previous fetch if a new REQUEST arrives before it finishes
}takeLatest automatically cancels the in-flight fetch when a second FETCH_USER_REQUEST arrives. Getting the same behavior with Thunk requires manual AbortController setup.
Intermediate: search input with debounce
With Thunk, you store a timer at module level and manage it yourself:
// Thunk - manual debounce with a module-level timer
let searchTimer;
const searchUsers = (query) => (dispatch) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
dispatch({ type: 'SEARCH_USERS_REQUEST' });
api.searchUsers(query)
.then(users => dispatch({ type: 'SEARCH_USERS_SUCCESS', payload: users }))
.catch(() => dispatch({ type: 'SEARCH_USERS_FAILURE' }));
}, 300);
};
// Problem: module-level timer persists across tests and hot reloadsSaga ships with a debounce effect:
// Saga - built-in debounce, no external state
import { debounce, call, put } from 'redux-saga/effects';
function* searchUsersSaga({ payload: query }) {
try {
const users = yield call(api.searchUsers, query);
yield put({ type: 'SEARCH_USERS_SUCCESS', payload: users });
} catch {
yield put({ type: 'SEARCH_USERS_FAILURE' });
}
}
export function* watchSearch() {
yield debounce(300, 'SEARCH_USERS', searchUsersSaga);
// waits 300ms after the last SEARCH_USERS action before calling searchUsersSaga
}The Saga version has no module-level state and resets cleanly between tests. It is also shorter.
Advanced: fallback API with race condition
// Thunk - Promise.race with manual null-checking, losing request keeps running
const searchWithFallback = (query) => (dispatch) =>
Promise.race([
api.searchPrimary(query).catch(() => null),
api.searchFallback(query).catch(() => null)
])
.then(result => {
if (!result) return dispatch({ type: 'SEARCH_FAILURE' });
dispatch({ type: 'SEARCH_SUCCESS', payload: result });
});
// The losing request keeps running in the background - no cancellation// Saga - race effect cancels the loser automatically
import { race, call, put } from 'redux-saga/effects';
function* searchWithFallback({ payload: query }) {
const { primary, fallback } = yield race({
primary: call(api.searchPrimary, query),
fallback: call(api.searchFallback, query)
});
if (primary) yield put({ type: 'SEARCH_SUCCESS', payload: primary });
else if (fallback) yield put({ type: 'SEARCH_SUCCESS', payload: fallback });
else yield put({ type: 'SEARCH_FAILURE' });
}
// When primary resolves first, Saga cancels the fallback call automaticallyWhen primary resolves, Saga cancels fallback. The Thunk version lets both requests run to completion regardless of which wins. On mobile or slow connections, that unused request wastes bandwidth every time.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.