Debugging a Mysterious Cypress Failure: It Was the return All Along

This is another "post-mortem" blog after debugging a mysterious automation testing issue. TL;DR: Our test, which validates database entries, didn’t fail even when the backend service responsible for writing those entries was offline. The culprit? Cypress skipped the validation code entirely because the command wasn’t properly queued—thanks to the missing return
keyword and misuse of chainables.
This post is the final wrap-up of what we learned. Blog credits go to GPT-4.1 and Claude Sonnet 4.0 for helping organize the thoughts, and credits for the painful lesson go to myself, and readers of this blog lol.
Cypress's command queue system is fundamentally different from standard JavaScript async/await patterns. Understanding why the return
keyword is critical and how command queue execution works with and without proper returns can make the difference between reliable tests and flaky, unpredictable ones.
The Cypress Command Queue: A Different Beast
Cypress doesn't execute commands immediately. Instead, it builds a queue of commands that execute sequentially, with automatic retries and intelligent waiting. This queue-based execution model is what makes Cypress powerful, but it also requires developers to understand how to properly integrate custom logic into this flow.
Chainables vs Promises: Understanding the Fundamental Difference
Many developers new to Cypress assume that Cypress commands return Promises because they have .then()
methods. This is a critical misconception that leads to incorrect usage patterns.
What Are Chainables?
Cypress chainables are not Promises. They're special objects that:
- Get added to Cypress's internal command queue
- Execute sequentially, one after another
- Have built-in retry logic and intelligent waiting
- Can be chained using
.then()
,.should()
,.and()
, etc. - Are managed entirely by Cypress's test runner
What Are Promises?
JavaScript Promises are:
- Native JavaScript objects for handling async operations
- Execute immediately when created
- Resolve or reject once, then remain settled
- Work with
async/await
syntax - Managed by the JavaScript event loop
Key Differences in Practice
Promise behavior:
// This executes immediately
const promise = fetch('/api/data');
promise.then(response => console.log('Got response'));
console.log('This logs first, before the response');
Chainable behavior:
// This gets queued, doesn't execute immediately
const chainable = cy.request('/api/data');
chainable.then(response => console.log('Got response'));
cy.log('This logs first in the queue');
// Output order: "This logs first in the queue", then "Got response"
The .then()
Method: Similar but Different
Both Promises and chainables have .then()
methods, but they behave differently:
- Promise
.then()
: Executes when the Promise resolves - Chainable
.then()
: Gets added to the command queue and executes in sequence
This is why you can't use async/await
with Cypress commands - they're not Promises that can be awaited.
The Critical Importance of the "return" Keyword
Without "return": Breaking the Chain
When you forget to return a Cypress chainable, you're essentially telling Cypress: "Don't wait for this operation, just move on." This breaks the command queue and can lead to race conditions.
❌ Bad - Without Return:
Cypress.Commands.add('badValidation', (expectedData, clientId, dbName) => {
// This logging happens, but Cypress doesn't wait for the DB query
cy.task('log', { message: 'Starting validation...' });
// Cypress immediately moves to the next command in the test
// The DB query might still be running when the test continues
cy.task('queryDataMySql', {
sql: `SELECT * FROM ${dbName}.all_data WHERE client_id = '${clientId}'`
}).then((result) => {
// This might execute AFTER the test has already finished!
expect(result.resultData.length).to.be.greaterThan(0);
});
// No return = Cypress doesn't wait = broken chain
});
✅ Good - With Return:
Cypress.Commands.add('goodValidation', (expectedData, clientId, dbName) => {
cy.task('log', { message: 'Starting validation...' });
// The "return" tells Cypress: "Wait for this entire chain to complete"
return cy.task('queryDataMySql', {
sql: `SELECT * FROM ${dbName}.all_data WHERE client_id = '${clientId}'`
}).then((result) => {
expect(result.resultData.length).to.be.greaterThan(0);
// Must return a chainable here too, for further chaining
return cy.wrap(result);
});
});
Command Queue Execution: With vs Without Return
Scenario 1: Without Return - Race Conditions
it('demonstrates broken chaining', () => {
cy.visit('/survey');
// This command doesn't return properly
cy.badValidation(expectedData, clientId, dbName);
// This might run BEFORE the validation completes!
cy.get('#submit-button').click();
// Test might pass/fail unpredictably
});
Queue execution:
cy.visit('/survey')
- executes and completescy.badValidation()
- starts but Cypress doesn't waitcy.get('#submit-button').click()
- executes immediately- Database query from step 2 might still be running
Scenario 2: With Return - Proper Sequencing
it('demonstrates proper chaining', () => {
cy.visit('/survey');
// This command returns properly
cy.goodValidation(expectedData, clientId, dbName);
// This waits for validation to complete
cy.get('#submit-button').click();
// Reliable, predictable execution
});
Queue execution:
cy.visit('/survey')
- executes and completescy.goodValidation()
- executes completely (including DB query and assertions)cy.get('#submit-button').click()
- executes after validation is done- Predictable, reliable test
Why You Must Return Chainables in .then()
Blocks
Inside .then()
blocks, you have two choices:
- Return a Cypress chainable (like
cy.wrap()
,cy.task()
, etc.) - Return a primitive value (string, number, boolean, object)
If you're doing async work inside .then()
, you must return a Cypress chainable to maintain the queue.
The Problem with Primitive Returns
❌ This breaks chaining for async operations:
return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
let passed = true;
try {
// Async assertions happen here
expect(result.resultData.length).to.be.greaterThan(0);
} catch (err) {
cy.task('log', { message: `Failed: ${err.message}` }); // This is async!
passed = false;
}
return passed; // ❌ Primitive return while async work is happening
});
✅ This maintains the chain:
return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
let passed = true;
try {
expect(result.resultData.length).to.be.greaterThan(0);
} catch (err) {
cy.task('log', { message: `Failed: ${err.message}` });
passed = false;
}
return cy.wrap(passed); // ✅ Chainable return ensures proper queuing
});
When and Why to Use cy.wrap()
: Bringing Values into the Queue
One of the most important but misunderstood concepts in Cypress is knowing when to use cy.wrap()
. This function is your bridge between the synchronous JavaScript world and Cypress's asynchronous command queue.
What Does cy.wrap()
Do?
cy.wrap()
takes any JavaScript value (primitive, object, array, etc.) and wraps it in a Cypress chainable. This allows you to:
- Bring non-Cypress values into the command queue
- Enable chaining with
.then()
,.should()
, etc. - Ensure proper sequencing in your tests
When You MUST Use cy.wrap()
1. Returning Primitive Values from .then()
Blocks with Async Work
❌ This breaks the chain:
Cypress.Commands.add('validateData', (expectedFields, clientId, dbName) => {
return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
let passed = true;
try {
expect(result.resultData.length).to.be.greaterThan(0);
} catch (err) {
// This cy.task() is async, but we're returning a primitive!
cy.task('log', { message: `Failed: ${err.message}` });
passed = false;
}
return passed; // ❌ Primitive return while async work is happening
});
});
✅ This maintains the chain:
Cypress.Commands.add('validateData', (expectedFields, clientId, dbName) => {
return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
let passed = true;
try {
expect(result.resultData.length).to.be.greaterThan(0);
} catch (err) {
cy.task('log', { message: `Failed: ${err.message}` });
passed = false;
}
return cy.wrap(passed); // ✅ Wrapped primitive maintains the queue
});
});
2. Working with Regular JavaScript Values
❌ This doesn't work for chaining:
Cypress.Commands.add('processArray', (items) => {
const processed = items.map(item => item.toUpperCase());
return processed; // ❌ Not chainable
});
// Usage fails:
cy.processArray(['a', 'b', 'c']).should('have.length', 3); // Error!
✅ This enables chaining:
Cypress.Commands.add('processArray', (items) => {
const processed = items.map(item => item.toUpperCase());
return cy.wrap(processed); // ✅ Now it's chainable
});
// Usage works:
cy.processArray(['a', 'b', 'c']).should('have.length', 3); // Success!
3. Conditional Logic with Chainables
Cypress.Commands.add('conditionalValidation', (shouldValidate, data) => {
if (shouldValidate) {
return cy.task('validateData', data);
} else {
// Must wrap to return a chainable consistently
return cy.wrap('skipped');
}
});
When You DON'T Need cy.wrap()
1. Returning Cypress Commands Directly
Cypress.Commands.add('getElement', (selector) => {
return cy.get(selector); // ✅ Already a chainable
});
2. Simple Primitive Returns (No Async Work)
Cypress.Commands.add('calculateTotal', (items) => {
return cy.wrap(items).then((itemList) => {
const total = itemList.reduce((sum, item) => sum + item.price, 0);
return total; // ✅ No async work, primitive is fine
});
});
Quick Decision Guide: Do I Need cy.wrap()
?
Ask yourself:
- Am I returning from a custom command? → Probably yes
- Am I doing async work (cy.task, cy.request, etc.) before returning a primitive? → Definitely yes
- Do I want to chain Cypress methods on my return value? → Yes
- Am I returning a Cypress command directly? → No, it's already chainable
- Am I just passing through a value with no async work? → Maybe not needed
Real-World Example: The Evolution of a Command
Let's trace through how the validateNADataRecordMatch
command evolved to understand the importance of proper returns:
Version 1: Broken Chain (Common Mistake)
Cypress.Commands.add('validateNADataRecordMatch', (expectedFields, clientId, dbName) => {
cy.task('log', { message: `Starting validation for ${clientId}` });
cy.task('queryDataMySql', {
sql: `SELECT * FROM ${dbName}.na_data WHERE client_id = '${clientId}'`
}).then((result) => {
// Validation logic here...
expect(result.resultData.length).to.be.greaterThan(0);
});
// ❌ No return = Cypress doesn't wait for the DB query and validation
});
What happens: The test continues immediately after calling this command, potentially before the database query completes.
Version 2: Fixed Chain with Proper Returns
Cypress.Commands.add('validateNADataRecordMatch', (expectedFields, clientId, dbName) => {
cy.task('log', { message: `Starting validation for ${clientId}` });
return cy.task('queryDataMySql', {
sql: `SELECT * FROM ${dbName}.na_data WHERE client_id = '${clientId}'`
}).then((result) => {
let passed = true;
try {
expect(result.resultData.length).to.be.greaterThan(0);
const row = result.resultData[0];
Object.entries(expectedFields).forEach(([key, expectedVal]) => {
if (expectedVal === null) {
expect(row[key]).to.be.null;
} else if (expectedVal === '_ANY_') {
expect(row[key]).to.not.be.oneOf([null, undefined, '']);
} else {
expect(row[key]).to.eq(expectedVal);
}
});
} catch (err) {
// This cy.task() call is async!
cy.task('log', { message: `❌ Validation failed: ${err.message}` });
passed = false;
}
// Without cy.wrap(), Cypress won't wait for the logging task above
return cy.wrap(passed); // ✅ Essential for proper sequencing
});
});
Key improvements:
- Returns the main chain - Cypress waits for the entire operation
- Uses
cy.wrap(passed)
- Maintains chainability even with async error logging - Proper error handling - Logs errors without breaking the chain
Why async/await
Doesn't Work with Cypress
Understanding why you can't use async/await
with Cypress commands reinforces the importance of proper returns:
❌ Don't do this:
Cypress.Commands.add('brokenAsyncCommand', async (clientId) => {
await cy.visit('/page'); // ❌ This doesn't work as expected
const result = await cy.task('queryDataMySql', { sql: '...' }); // ❌ Breaks the queue
return result;
});
✅ Do this instead:
Cypress.Commands.add('properCommand', (clientId) => {
return cy.visit('/page').then(() => {
return cy.task('queryDataMySql', { sql: '...' });
});
});
Why this matters:
- Cypress commands don't resolve immediately - they're queued
await
expects a Promise - Cypress commands return chainables- Mixing async/await breaks the queue - Cypress loses control of execution order
Common Mistakes and How to Fix Them
Mistake 1: Forgetting cy.wrap()
in Error Handling
// ❌ Common mistake - async logging but primitive return
.then((result) => {
if (result.error) {
cy.task('log', { message: 'Error occurred' }); // Async!
return false; // Primitive return = broken chain
}
return result.data; // Also primitive = broken chain
});
// ✅ Fixed with cy.wrap()
.then((result) => {
if (result.error) {
cy.task('log', { message: 'Error occurred' }); // Async!
return cy.wrap(false); // Chainable return = maintained chain
}
return cy.wrap(result.data); // Chainable return = maintained chain
});
Mistake 2: Treating Chainables Like Promises
// ❌ Wrong approach
Cypress.Commands.add('badExample', async (url) => {
const response = await cy.request(url); // Doesn't work!
return response.body;
});
// ✅ Correct approach
Cypress.Commands.add('goodExample', (url) => {
return cy.request(url).then((response) => {
return cy.wrap(response.body);
});
});
Key Principles for Reliable Cypress Commands
- Always return the outermost Cypress command in custom commands
- Return
cy.wrap()
for primitive values when async work is involved - Use
.then()
instead ofasync/await
for accessing command results - Let Cypress manage the queue - don't try to control timing manually
- Chain everything - keep all async work in the Cypress command queue
Debugging Tip: Identifying Chain Breaks
If your tests are flaky or behaving unpredictably, look for these patterns:
- Custom commands without
return
statements .then()
blocks that return primitives while doing async work- Mixing Cypress commands with native Promises or
async/await
- Logic that depends on timing rather than Cypress's queue
Conclusion
The return
keyword in Cypress isn't just good practice—it's essential for reliable tests. It ensures that:
- Commands execute in the correct order
- The test waits for async operations to complete
- Assertions run at the right time
- Commands can be properly chained together
Master the command queue, respect the chainables, and always return what Cypress needs to maintain control. Your tests will be more reliable, predictable, and maintainable.
Subscribe to my newsletter
Read articles from Jie Han directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
