DEV Community

Cover image for I Built a Cart Recovery System That Saved My Client $127K in 6 Months
mohammad shajalal
mohammad shajalal

Posted on

I Built a Cart Recovery System That Saved My Client $127K in 6 Months

I Built a Cart Recovery System That Saved My Client $127K in 6 Months (Here's the Tech Stack)
TL;DR: Frustrated by 70% cart abandonment on an eCommerce project, I built CartResQ - an AI-powered recovery system. It now recovers 35-40% of abandoned carts automatically. Here's how I built it, the tech decisions that mattered, and the code that drives it.

The Problem That Kept Me Up at Night
Picture this: You're running an online store, getting decent traffic, but 7 out of 10 people who add items to their cart just... leave. No purchase. No explanation. Just gone.
That was my client's reality. We were hemorrhaging $50K+ monthly in potential revenue.
I knew about cart abandonment recovery tools, but they all had the same problems:
❌ Generic email templates that felt like spam
❌ Manual setup requiring constant tweaking
❌ Expensive (starting at $200+/month)
❌ No real personalization beyond "Hi {firstname}"
❌ Single-channel (just email, no SMS or push)
So I did what any developer would do at 2 AM: I built my own solution.

Six months later, CartResQ has recovered $127,340 for that client alone, and I've turned it into a SaaS that's now helping 200+ stores.
This is the technical breakdown of how it works.

The Architecture: Keep It Simple, Scale Later
Tech Stack
Frontend: React + TypeScript + Tailwind CSS
Backend: Node.js + Express + TypeScript
Database: PostgreSQL (Supabase)
Queue: Bull + Redis
Email: SendGrid API
SMS: Twilio API
AI/ML: OpenAI GPT-4 + TensorFlow.js
Hosting: Vercel (Frontend) + Railway (Backend)
Monitoring: Sentry + PostHog

Why these choices?

TypeScript everywhere - Type safety prevented so many bugs during rapid iteration
PostgreSQL - JSONB columns for flexible cart data without sacrificing query performance
Bull Queue - Perfect for scheduling recovery emails at optimal times
Supabase - Got us real-time subscriptions and authentication basically free
Railway - Dead simple deployment, scales automatically

The Core Features (And How I Built Them)

  1. Real-Time Cart Tracking The first challenge: How do you track abandoned carts without slowing down the checkout? Here's the tracking script stores install: javascript// cartresq-tracker.js (function() { const CART_API = 'https://api.cartresq.com/v1/track';

// Lightweight tracking with debouncing
let cartData = null;
let debounceTimer = null;

function trackCart() {
// Capture cart state
cartData = {
items: window.cartItems || [],
total: window.cartTotal || 0,
sessionId: getOrCreateSession(),
timestamp: Date.now(),
device: getDeviceInfo()
};

// Debounce API calls (don't spam on every change)
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
  sendToAPI(cartData);
}, 2000);
Enter fullscreen mode Exit fullscreen mode

}

// Send via beacon API (non-blocking)
function sendToAPI(data) {
navigator.sendBeacon(
CART_API,
new Blob([JSON.stringify(data)], {type: 'application/json'})
);
}

// Track cart changes
window.addEventListener('cartUpdated', trackCart);

// Critical: Exit-intent detection
let mouseY = 0;
document.addEventListener('mousemove', (e) => {
mouseY = e.clientY;
});

document.addEventListener('mouseout', (e) => {
if (mouseY < 10) { // Mouse moving to close tab
trackCart();
showExitIntent(); // Trigger popup
}
});
})();
Key insights:
✅ Used navigator.sendBeacon() - Guarantees delivery even if user closes tab
✅ Debouncing prevents API spam (was hitting rate limits initially)
✅ Exit-intent detection is just mouse position tracking (so simple it's stupid)
Performance Impact
Before optimization: +420ms page load
After optimization: +18ms page load
The secret? Async loading and minimal DOM manipulation.

  1. AI-Powered Send Time Optimization This is where it gets interesting. Not all customers are the same. The Problem: Sending recovery emails at the same time (e.g., 1 hour after abandonment) to everyone is suboptimal. The Solution: Train a model to predict the best send time for each customer. python# training_model.py (TensorFlow) import tensorflow as tf import numpy as np

def build_model():
model = tf.keras.Sequential([
tf.keras.layers.Dense(64, activation='relu', input_shape=(10,)),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(32, activation='relu'),
tf.keras.layers.Dense(1, activation='sigmoid')
])

model.compile(
    optimizer='adam',
    loss='binary_crossentropy',
    metrics=['accuracy']
)

return model
Enter fullscreen mode Exit fullscreen mode

Features we track

features = [
'hour_of_abandonment', # Time of day
'day_of_week', # Weekend vs weekday
'cart_value', # Higher value = faster response?
'previous_purchases', # Returning customer?
'time_on_site', # Engagement level
'device_type', # Mobile vs desktop
'items_in_cart', # Cart size
'viewed_checkout', # Got to checkout page?
'email_open_history', # Past engagement
'discount_sensitivity' # Responded to discounts before?
]

Train on historical data

model = build_model()
model.fit(training_data, labels, epochs=50, batch_size=32)

Save for inference

model.save('send_time_optimizer.h5')
Results:

Before ML: 18% email open rate
After ML: 34% email open rate

Almost doubled engagement just by sending at the right time.

  1. Dynamic Email Personalization (Beyond {firstname}) Everyone uses merge tags. We went deeper. javascript// email-generator.js async function generateEmail(abandonedCart, customer) { const context = { customerName: customer.firstName, items: abandonedCart.items, browsedProducts: await getBrowsingHistory(customer.id), cartValue: abandonedCart.total, isReturning: customer.orderCount > 0, lastPurchase: customer.lastOrderDate, priceDrops: await checkPriceDrops(abandonedCart.items), lowStock: await checkInventory(abandonedCart.items) };

// AI-generated subject lines
const subject = await generateSubject(context);

// Personalized product recommendations
const recommendations = await getRecommendations(
abandonedCart.items,
customer.purchaseHistory
);

// Dynamic urgency triggers
const urgencyMessage = createUrgencyMessage(context);

return {
subject,
body: renderTemplate('cart-recovery', {
...context,
recommendations,
urgencyMessage,
discountOffer: calculateOptimalDiscount(context)
})
};
}

function createUrgencyMessage(context) {
if (context.lowStock.some(item => item.quantity < 3)) {
return ⚠️ Only ${context.lowStock[0].quantity} left in stock!;
}

if (context.priceDrops.length > 0) {
return 🎉 Good news! ${context.priceDrops[0].name} is now $${context.priceDrops[0].newPrice};
}

return 'Complete your order before your cart expires!';
}
Real Example:
Generic Email:

"Hi Sarah, you left items in your cart. Come back and complete your order!"

CartResQ Email:

"Sarah, the Wireless Headphones you added are now $20 off! Plus, only 2 left in stock. We saved your cart for 24 hours."

Conversion rate: 14% vs 34% 🚀

  1. The Smart Discount Engine The Challenge: How much discount should you offer?

Too little → Customer doesn't come back
Too much → You kill your margins

The Solution: Game theory + historical data
javascript// discount-calculator.js
function calculateOptimalDiscount(customer, cart) {
const baseRules = {
newCustomer: {
minDiscount: 0.10, // 10%
maxDiscount: 0.15 // 15%
},
returning: {
minDiscount: 0.05,
maxDiscount: 0.10
},
highValue: {
minDiscount: 0, // No discount needed
maxDiscount: 0.05
}
};

// Determine customer segment
let segment = 'newCustomer';
if (customer.orderCount > 0) segment = 'returning';
if (cart.total > 200) segment = 'highValue';

// A/B test different discount levels
const testGroup = customer.id % 3;

const discounts = {
0: baseRules[segment].minDiscount,
1: (baseRules[segment].minDiscount + baseRules[segment].maxDiscount) / 2,
2: baseRules[segment].maxDiscount
};

// Track which discount level converts best
logDiscountTest({
customerId: customer.id,
segment,
discountOffered: discounts[testGroup],
testGroup
});

return discounts[testGroup];
}
Outcome:

Average discount given: 8.2%
Recovery rate: 37%
Profit margin protected: Yes

  1. Multi-Channel Recovery (Email → SMS → Push) One channel isn't enough. Here's the sequence: javascript// recovery-sequence.js async function executeRecoverySequence(abandonedCart) { const customer = await getCustomer(abandonedCart.customerId);

// Sequence with intelligent delays
const sequence = [
{
channel: 'email',
delay: getOptimalDelay(customer, 'email'), // ML prediction
template: 'gentle-reminder'
},
{
channel: 'email',
delay: 24 * 60 * 60 * 1000, // 24 hours
template: 'value-proposition',
include: ['social-proof', 'reviews']
},
{
channel: 'sms',
delay: 48 * 60 * 60 * 1000, // 48 hours
template: 'discount-offer',
condition: () => customer.smsOptIn && abandonedCart.total > 50
},
{
channel: 'email',
delay: 72 * 60 * 60 * 1000, // 72 hours
template: 'final-call',
include: ['discount', 'urgency']
}
];

// Queue messages
for (const message of sequence) {
if (message.condition && !message.condition()) continue;

await addToQueue({
  type: message.channel,
  delay: message.delay,
  data: {
    customer,
    cart: abandonedCart,
    template: message.template,
    includes: message.include || []
  }
});
Enter fullscreen mode Exit fullscreen mode

}
}

// Bull queue worker
recoveryQueue.process(async (job) => {
const { type, data } = job.data;

switch(type) {
case 'email':
await sendEmail(data);
break;
case 'sms':
await sendSMS(data);
break;
case 'push':
await sendPushNotification(data);
break;
}

// Track delivery and opens
await trackMessage({
channel: type,
customerId: data.customer.id,
cartId: data.cart.id,
timestamp: Date.now()
});
});

The Database Schema (PostgreSQL)
sql-- Core tables
CREATE TABLE abandoned_carts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id VARCHAR(255) NOT NULL,
customer_id UUID REFERENCES customers(id),
store_id UUID REFERENCES stores(id) NOT NULL,
items JSONB NOT NULL,
total DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
abandoned_at TIMESTAMP NOT NULL,
recovered BOOLEAN DEFAULT FALSE,
recovered_at TIMESTAMP,
device_info JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_abandoned_at ON abandoned_carts(abandoned_at);
CREATE INDEX idx_store_recovery ON abandoned_carts(store_id, recovered);
CREATE INDEX idx_customer ON abandoned_carts(customer_id);

-- Track recovery messages
CREATE TABLE recovery_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cart_id UUID REFERENCES abandoned_carts(id),
channel VARCHAR(50) NOT NULL, -- email, sms, push
template VARCHAR(100),
sent_at TIMESTAMP NOT NULL,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
converted_at TIMESTAMP,
discount_offered DECIMAL(5,2),
created_at TIMESTAMP DEFAULT NOW()
);

-- A/B test tracking
CREATE TABLE ab_tests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cart_id UUID REFERENCES abandoned_carts(id),
variant VARCHAR(50),
metric VARCHAR(50),
value DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW()
);
Why JSONB for cart items?

Flexibility - Every eCommerce platform structures data differently
Performance - PostgreSQL JSONB is indexed and queryable
Simplicity - No complex JOIN logic for product details

Real Results (The Numbers Don't Lie)
Case Study: Fashion Boutique
Before CartResQ:

Cart abandonment rate: 73%
Manual email recovery: 8% success rate
Time spent: 15 hours/week

After CartResQ (6 months):

Cart abandonment rate: 58% (15% reduction)
Automated recovery rate: 37%
Revenue recovered: $127,340
Time spent: 0 hours/week (fully automated)
ROI: 6,735% on the $79/month plan

Case Study: Electronics Store
Challenge: 87% mobile abandonment
Solution:

Mobile-specific exit-intent popups
SMS recovery for mobile users
Optimized mobile email templates

Results:

Mobile abandonment: 87% → 59%
Monthly recovered revenue: $43,200
Average order value: +18%

Lessons Learned (The Hard Way)
❌ What Didn't Work

  1. Over-engineered Machine Learning Initially tried using a complex neural network with 30+ features. Accuracy: meh. Switched to a simpler model with 10 features. Accuracy: great. Lesson: Start simple, add complexity only when needed.
  2. Too Many Emails First version sent 5 emails over 7 days. Unsubscribe rate: 12%. Reduced to 3 emails. Unsubscribe rate: 2%. Lesson: Respect the inbox. Quality over quantity.
  3. Generic Popups "Get 10% off!" popups everywhere. Annoying. Low conversion. Switched to context-aware popups (only on exit-intent, personalized offers). Conversion: +240%. Lesson: Context matters more than the offer. ✅ What Worked Surprisingly Well
  4. Honest, Human Subject Lines Instead of "URGENT: Your cart is about to expire!!!" We use: "Hey, did you mean to leave these behind?" Open rates: +45%
  5. Price Drop Alerts If ANY item in their cart goes on sale, instant notification. Conversion rate: 54% (insane)
  6. Post-Purchase Recovery Don't just stop at checkout. We send "complete the look" emails post-purchase. Revenue lift: +23% per customer

The Business Side
Pricing Strategy
Starter: $29/month (up to 500 carts)
Growth: $79/month (up to 2000 carts) ⭐ Most Popular
Pro: $179/month (up to 10,000 carts)
Enterprise: Custom (unlimited)
Why these tiers?

$29 - Covers hosting costs, gets people in the door
$79 - Sweet spot for most stores (avg recovery: $3,500/month)
$179 - High-volume stores, ROI still 2000%+

Customer Acquisition
What worked:
✅ Dev.to articles (like this one)
✅ Product Hunt launch (400 signups)
✅ Shopify App Store (steady 10-15 installs/day)
✅ Direct outreach to eCommerce agencies
What didn't:
❌ Google Ads (too expensive for our ACV)
❌ Cold email (spam filters killed us)
❌ Facebook Ads (audience too broad)

CartResq is positioned to capture significant market share in the $2.4B abandoned cart recovery market by delivering enterprise-grade features at small business prices with unmatched simplicity.
Key Success Factors:

Proven Product-Market Fit: 500+ customers, $50K MRR, 22% recovery rate
Large, Growing Market: $6.3T e-commerce, 70% abandonment rate
Clear Differentiation: 5x cheaper, 10x easier than competitors
Strong Unit Economics: 10:1 LTV:CAC, 3-month payback, path to 25%+ margins
Experienced Team: Founders with e-commerce and SaaS experience
Capital Efficient: Break-even by Month 10, profitable by Month 12

Investment Opportunity:

Seed Round: $500K
Valuation: $2.5M post-money
Projected Year 3 Revenue: $5.3M
Projected Exit: Series A ($5M) in Year 2 or profitable exit at $10M+ valuation

CartResq is not just another SaaS tool—it's a revenue recovery engine that helps small businesses thrive by turning lost sales into loyal customers.

Top comments (0)