What is Strategy design pattern?
Strategy design pattern - a behavioral pattern that defines a family of algorithms, wraps each in its own object, and lets the context swap between them at runtime.
Theory
TL;DR
- Analogy: swapping engines at a pit stop - same car (context), different engine (strategy) for track, highway, or off-road
- Main idea: replace if/else chains by delegating behavior to injected objects
- Use when: 3+ algorithms for the same task, or user picks one at runtime
- Skip when: 2 options only. A ternary or two direct function calls is enough.
Quick example
class PayPalStrategy { pay(amount) { return `Paid $${amount} via PayPal`; } }
class CardStrategy { pay(amount) { return `Paid $${amount} via Card`; } }
class Processor {
constructor(strategy) { this.strategy = strategy; }
process(amount) { return this.strategy.pay(amount); }
}
const p = new Processor(new PayPalStrategy());
console.log(p.process(100)); // "Paid $100 via PayPal"
p.strategy = new CardStrategy();
console.log(p.process(100)); // "Paid $100 via Card"Processor calls pay() and trusts the strategy to handle the rest. It never checks which payment method you passed in.
Key difference
Without Strategy, process() grows an if/else for every payment method. Add Crypto - open the file, edit the block, risk breaking existing behavior. With Strategy, you add one class and inject it. The context never changes. Algorithms evolve independently of the code that runs them.
When to use
- 3+ ways to do the same operation (sort, validate, compress, authenticate): Strategy
- User picks a behavior at runtime (payment method, login provider): Strategy
- Behavior changes based on config or environment: Strategy
- You have a
switchwith 5+ cases all doing "the same kind of thing": refactor to Strategy - 2 options, no runtime switching: use a ternary or two direct function calls
How JavaScript handles this
In V8, strategy objects are first-class values. When this.strategy.pay() runs, the engine resolves it via a dynamic property lookup through the prototype chain at call time. No compile-time binding, no vtable like in C++. Any object with a pay() method works as a valid strategy. Duck typing does the job that formal interfaces do in Java or C#. No implements keyword needed.
Common mistakes
Forgetting to validate the strategy in the constructor
const p = new Processor(); // no strategy passed
p.process(100); // TypeError: Cannot read properties of undefinedGuard it early:
constructor(strategy) {
if (!strategy?.pay) throw new Error('Strategy must implement pay()');
this.strategy = strategy;
}Sharing stateful strategies across contexts
class LoggingStrategy {
constructor() { this.log = []; }
pay(amount) { this.log.push(amount); return 'Paid'; }
}
const shared = new LoggingStrategy();
const p1 = new Processor(shared);
const p2 = new Processor(shared);
p1.process(100);
p2.process(200);
// shared.log is [100, 200] - state leaked between both processorsStrategies should be stateless. If you need state, create a fresh instance per context. This is a common source of subtle bugs in logging and analytics strategies.
Over-engineering for two options
// Unnecessary: two classes for a binary choice
class USDStrategy { pay() { /* ... */ } }
class EURStrategy { pay() { /* ... */ } }Two options? Write a ternary. Wait for 3+ real runtime cases before reaching for Strategy.
No null check after dynamic swaps
p.strategy = null;
p.process(100); // crashIf strategies get swapped at runtime, guard against null:
process(amount) {
return this.strategy?.pay(amount) ?? 'No strategy configured';
}Real-world usage
- Passport.js:
passport.use(new LocalStrategy(...))andpassport.use(new GoogleStrategy(...))- every auth provider is a Strategy plugged into the same middleware context. Every codebase I've seen using Passport already has this pattern in it, whether the team named it or not. - TanStack Table v8+: comparator functions injected via column meta for runtime sort switching
- Node.js zlib:
createDeflate(),createGzip(),createBrotliCompress()- each is a distinct compression strategy in a stream pipeline - Lodash:
_.sortBywith iteratee functions acts as lightweight Strategy without class overhead
Follow-up questions
Q: How is Strategy different from Template Method?
A: Template Method puts the algorithm skeleton in a base class and lets subclasses override specific steps via inheritance. Strategy replaces the whole algorithm via composition. You swap behavior without creating a subclass.
Q: Strategy vs Factory - what is the difference?
A: Factory creates objects. Strategy selects which algorithm runs at runtime. They often appear together: a factory can instantiate the right strategy based on config or user input.
Q: When does Strategy add unnecessary overhead?
A: When you have 2 options and no runtime swap. For performance-critical code, extra object allocation and method indirection add cost. In those cases, pass a function directly. Array.sort(comparatorFn) does exactly this without wrapping anything in a class.
Q: How would you refactor an if/else chain to Strategy?
A: Extract each branch into a class with a consistent method name like execute(). Replace the conditional block with a context that receives the strategy via constructor. Adding a new case means adding a new class, not editing existing logic.
Q: How does Strategy work in functional programming?
A: Higher-order functions replace classes. const sorter = (strategy) => (items) => [...items].sort(strategy) - pass a comparator, get a sorter. Same concept, less ceremony.
Q (senior): In a microservices architecture, how would you manage strategies distributed across services?
A: Use a registry pattern combined with service discovery. The context fetches the right strategy via an API call (REST or gRPC). Add circuit breakers for the remote fetch. If the strategy service is down, fall back to a local default. This is how feature flag systems and A/B testing platforms handle distributed strategy selection at scale.
Examples
Basic: Swappable payment strategies
class PayPalStrategy {
pay(amount) { return `Paid $${amount} via PayPal`; }
}
class CreditCardStrategy {
pay(amount) { return `Paid $${amount} via Credit Card`; }
}
class CryptoStrategy {
pay(amount) { return `Paid $${amount} via Crypto`; }
}
class PaymentProcessor {
constructor(strategy) {
if (!strategy?.pay) throw new Error('Invalid strategy');
this.strategy = strategy;
}
setStrategy(strategy) { this.strategy = strategy; }
process(amount) { return this.strategy.pay(amount); }
}
const processor = new PaymentProcessor(new PayPalStrategy());
console.log(processor.process(50)); // "Paid $50 via PayPal"
processor.setStrategy(new CryptoStrategy());
console.log(processor.process(50)); // "Paid $50 via Crypto"Three payment methods, zero conditionals in PaymentProcessor. Adding a fourth means writing one new class. Nothing else changes.
Intermediate: Express auth middleware with pluggable strategies
class EmailStrategy {
async auth(req) {
const { email, password } = req.body;
// Simulate DB lookup
return email === 'user@example.com' && password === 'secret';
}
}
class OAuthStrategy {
async auth(req) {
const { token } = req.body;
// Simulate token validation
return token === 'valid-oauth-token';
}
}
const authMiddleware = (strategy) => async (req, res, next) => {
const isValid = await strategy.auth(req);
if (isValid) return next();
res.status(401).send('Unauthorized');
};
app.post('/login', authMiddleware(new EmailStrategy()));
app.post('/oauth/callback', authMiddleware(new OAuthStrategy()));This is how Passport.js works at its core. The middleware calls auth() and either forwards or rejects. Adding a new provider - SAML, API key, magic link - means adding a class, not touching the middleware function.
Advanced: Sortable React table with runtime strategy switching
const useSortableData = (items, initialConfig = null) => {
const [sortConfig, setSortConfig] = useState(initialConfig);
const comparators = {
name: (a, b) => a.name.localeCompare(b.name),
age: (a, b) => a.age - b.age,
// Secondary sort: score desc, then name asc on tie
score: (a, b) => b.score - a.score || a.name.localeCompare(b.name),
};
const sortedItems = useMemo(() => {
if (!sortConfig?.key || !comparators[sortConfig.key]) return items;
return [...items].sort(comparators[sortConfig.key]); // spread prevents mutation
}, [items, sortConfig]);
return { items: sortedItems, sortConfig, setSortConfig };
};
// In the component:
// <button onClick={() => setSortConfig({ key: 'score' })}>Sort by score</button>Two things worth noting here. First, [...items] before sort - Array.sort() mutates the original array, which breaks React's immutability expectations. Without the spread, every sort call modifies the source list in place. Second, the score comparator uses a secondary sort: score descending, then name alphabetically when scores tie. This kind of multi-field logic is where Strategy pays off. You name it, test it in isolation, and swap it without touching the component.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.