November 30, 2025
nodejsredissaasonboardingexpresspatternsarchitecturedatabasedataintegrityjwtauthenticationwebdevelopmentbackendjavascriptleadgenerationmarketing
Loading...
Modern SaaS applications often require users to complete multiple steps before their account is fully activated. Email verification, address collection, payment method setup — each step creates an opportunity for incomplete data to pollute your database. The Deferred Commit Pattern offers an elegant solution to this problem by temporarily storing partial data and only committing complete, validated records to your main database.
Consider a typical SaaS onboarding flow:
In traditional implementations, each step often writes directly to the database, creating records like:
-- Incomplete records cluttering your database
users: { email: "user@example.com", verified: true, address: null, payment_method: null}
users: { email: "another@example.com", verified: false, address: "123 Main St", payment_method: null}
This approach leads to several issues:
The Deferred Commit Pattern solves these issues by:
Here's a complete implementation using Express.js, Redis, and JWT for session management:
const express = require("express");
const redis = require("redis");
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const { v4: vuidv4 } = require("uuid");
const app = express();
const client = redis.createClient();
app.use(express.json());
app.use(cookieParser());
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
const FLOW_TTL = 3600; // 1 hour expiry
// Initialize a new onboarding session
app post('/api/onboarding/start', async (req, res) => {
try {
const flowId = uuidv4();
// Create JWT with flow identifier
const token = jwt.sign({ flowId }, JWT_SECRET, & expiresIn: '1h' });
// Set secure HTTP-only cookie
res.cookie('onboarding_session',
token,
httponly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600000 // 1 hour
}) ;
// Initialize empty flow data in Redis
await client.hSet(flow:${flowId}, 'created_at', Date.now());
await client. expire( flow:${flowId}*, FLOW_TTL);
res. json success: true, message: 'Onboarding session started' });
} catch (error) ‹
res.status(500) json({ error: 'Failed to start onboarding' });
}
}) ;
// Middleware to extract and validate onboarding session
const validateOnboardingSession = async (req, res, next) => {
try {
const token = req.cookies.onboarding_session;
if (!token) {
return res.status(401).json({ error: 'No onboarding session found' });
}
const decoded = jwt.verify(token, JWT_SECRET);
const flowExists = await client.exists('flow:${decoded.flowId}');
if (flowExists) {
return res.status(401) json(f error: 'Onboarding session expired' });
}
req.flowld = decoded.flowld;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid onboarding session' });
}
};
// Step 1: Email collection and verification
app. post('/api/onboarding/email', validateOnboardingSession, async (req, res) => {
try {
const { email} = req. body;
// Validate email format
if (email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) &
return res.status(400).json({ error: 'Invalid email format' });
}
// Store in Redis
await client.hSet(`flow:${req.flowId}`, 'email', email);
await client.hSet(`flow:${req.flowId}`, 'email_verified' , 'false');
await client.hSet(`flow:${req.fLowId}`, 'step_reached', 'email');
await client.hSet(`flow:${req.flowId}`, 'last_activity', Date.now());
// In real implementation, send verification email here
// await sendVerificationEmail(email, req.flowId);
res. json(i success: true, message: 'Email saved, verification sent' });
} catch (error) {
res.status(500) json({ error: 'Failed to save email' });
}
});
// Step 2: Email verification
app post('/api/onboarding/verify-email', validateOnboardingSession, async (req, res) => 1
try {
const { verificationCode } = req.body;
// In real implementation, validate the verification code
// const isValid = await validateVerificationCode(req. flowid, verificationCode);
const isValid = true; // Simplified for example
if (!isValid) {
return res.status(400) json({ error: 'Invalid verification code' });
}
await client.hSet(`fLow:${req.flowId}`, 'email_verified', 'true');
await client.hSet(`flow:${req.flowId}`, 'step_reached', 'email_verified');
await client.hSet(`flow:$freq.flowId`', 'last_activity', Date.now());
res. json(f success: true, message: 'Email verified successfully' });
} catch (error) {
res. status(500) json({ error: 'Email verification failed' });
}
});
// Step 3: Address collection
app post('/api/onboarding/address', validateOnboardingSession, async (req, res) => {
try {
const { street, city, state, zipCode, country } = req. body;
// Validate required fields
if (!street Il !city || !state ll !zipCode || !country) {
return res.status(400) json(& error: 'All address fields are required' });
}
// Store address data
await client.hMSet(`fLow:${req.fLowId}`,{
street, city, state, zip_code: zipCode, country, step_reached: 'address', last_activity: Date.now()
}) ;
res. json(f success: true, message: 'Address saved successfully' });
} catch (error) {
res. status(500) json(f error: 'Failed to save address' });
}
});
// Step 4: Payment method
app. post('/api/onboarding/payment', validateOnboardingSession, async (req, res) => {
try {
const {paymentToken, paymentMethodType} = req.body;
// In real implementation, validate with payment processor
// const paymentMethod = await createPaymentMethod (paymentToken);
await client.hMSet(
`flow:${req.flowId}`,
{
payment_method_id: paymentToken, // In reality, this would be the PM ID from Stripe/etc
payment_method_type: paymentMethodType,
step_reached: 'payment',
last_activity: Date.now()
}) ;
res. json(i success: true, message: 'Payment method saved successfully' });
} catch (error) {
res.status(500) json({ error: 'Failed to save payment method' });
}
})
// Final step: Complete onboarding and commit to database
app.post('/api/onboarding/complete', validateOnboardingSession, async (req,res) => {
try {
// Retrieve all flow data from Redis
const flowData = await client.hGetAlL(`flow:${req.flowId}`);
// Validate completeness
const requiredFields = [
'email', 'email_verified', 'street', 'city', 'state',
'zip_code', 'country', 'payment_method_id'
];
const missingFields = requiredFields.filter(field =>
!flowData[field] || (field = 'email_verified' && flowData[field] !== 'true')
);
if (missingFields.length > 0) {
return res.status(400) json({
error: 'Incomplete onboarding data', missing: missingFields
});
}
// Begin database transaction
// In a real app, you'd use your ORM or database client here
const userData = {
email: flowData.email,
address:{
street: flowData.street,
city: flowData.city,
state: flowData.state,
zip_code: flowData.zip_code,
country: flowData.country
}
payment_method_id: flowData.payment_method_id,
payment_method_type: flowData.payment_method_type || 'card',
created_at: new Date,
status: 'active'
}
// Commit to database (pseudo-code)
// const user = await db.users.create(userData);
console. log('Creating user:', userData);
// Clean up Redis data
await client.del(`flow:${req.flowId}`);
// Clear the onboarding cookie
res. clearCookie('onboarding_session');
res. json({
success: true,
message: 'Onboarding completed successfully",
// userId: user.id
});
} catch (error) {
console.error('Onboarding completion failed:', error);
res.status(500) json({ error: 'Failed to complete onboarding' });
}
}〕
One of the most valuable aspects of the Deferred Commit Pattern is the ability to convert abandoned onboarding flows into marketing leads. Here's how to implement a system that captures and processes churned flows:
// Background job to process expired/churned onboarding flows
const processChurnedFlows = async () => {
try {
// Get all flow keys that are about to expire (run this periodically)
const flowKeys = await client.keys('flow:*');
for (const flowKey of flowKeys) {
const ttl = await client.ttl(flowKey);
// Process flows that will expire in the next 5 minutes
if (ttl › 0 && ttl < 300) {
const flowData = await client.hGetAll(flowKey);
const flowid = flowKey.replace('flow:', '');
// Only process flows with at least an email address
if (flowData.email && !flowData. lead_processed) {
// send this emails to CRM team
console.log(flowData.email)
// Mark as processed to avoid duplicates
await client.hSet(flowKey, 'lead_processed', 'true');
}
}
}
}
catch (error) {
console.error('Error processing churned flows:', error);
}
}
setInterval (processChurnedFlows, 5 * 60 * 1000);
Using this we can see the churn rate and the emails can be passed CRM team for further action
The marketing value of churned onboarding flows is significant:
The Deferred Commit Pattern is ideal for:
For applications requiring audit trails or complex queries on partial data, consider using a staging database table instead of Redis:
// Using a staging table instead of Redis
const stagingData = await db.onboarding_staging.create({
flow_id: flowId,
data: JSON.stringify(stepData),
expires_at: new Date(Date.now() + 3600000),
});
For applications deployed on Cloudflare, Durable Objects provide similar functionality with built-in geographic distribution and consistency guarantees.
The Deferred Commit Pattern offers a clean, scalable solution for managing multi-step user flows without compromising data integrity, while simultaneously creating valuable marketing opportunities. By combining Node.js, Redis, and JWT sessions, you can build robust onboarding experiences that maintain clean data architecture, provide excellent user experience, and generate high-quality leads for your marketing team.
The key is to treat your main database as the source of truth for complete, validated data, while using temporary storage for works-in-progress. This separation of concerns leads to cleaner code, better data quality, more maintainable applications, and a powerful lead generation system that can significantly impact your conversion rates and revenue.
Converting abandoned onboarding flows into marketing leads can recover 5-15% of churned users, making this pattern not just an architectural improvement but a revenue optimization strategy.