18 min read
User Research
Master user research methodologies to understand behavior, motivations, and friction points that impact conversion rates.
Why User Research Matters for CRO
Quantitative data tells you what users do. User research tells you why.
code
The CRO Research Gap:
Analytics Data User Research
──────────────── ────────────────
72% bounce rate → "Page loads too slow on mobile"
3% cart abandonment → "Shipping costs were hidden until checkout"
15% form drop-off → "Too many required fields"
Low CTR on pricing → "Couldn't find the plan that fits my needs"
Without the "why", optimization is guesswork.Research ROI: Companies that invest in user research see 2-3x higher test win rates because they're solving real problems, not hypothetical ones.
Research Methods Overview
code
interface UserResearchMethods {
quantitative: {
surveys: 'Scale opinions across large samples';
analytics: 'Behavior patterns from tracking data';
heatmaps: 'Visual representation of interactions';
funnelAnalysis: 'Drop-off point identification';
};
qualitative: {
interviews: 'Deep understanding of motivations';
usabilityTests: 'Task completion observation';
sessionRecordings: 'Real user journey playback';
customerSupport: 'Common questions and complaints';
};
behavioral: {
clickTracking: 'What users click on';
scrollTracking: 'How far users scroll';
formAnalytics: 'Field-level interaction data';
exitIntentData: 'When and where users leave';
};
}On-Site Surveys
Survey Types and Timing
code
interface SurveyStrategy {
exitIntent: {
trigger: 'Mouse moves toward browser close';
goal: 'Understand abandonment reasons';
questions: [
'What stopped you from completing your purchase today?',
'Is there anything unclear about our product/pricing?'
];
};
postPurchase: {
trigger: 'Order confirmation page or email';
goal: 'Understand conversion drivers';
questions: [
'What almost stopped you from buying?',
'How did you hear about us?',
'What convinced you to purchase?'
];
};
onPage: {
trigger: 'Time on page or scroll depth';
goal: 'Gather feedback while engaged';
questions: [
'Did you find what you were looking for?',
'How would you rate this page?'
];
};
postTask: {
trigger: 'After specific action (signup, form submit)';
goal: 'Evaluate task experience';
questions: [
'How easy was it to complete this process?',
'What would have made this easier?'
];
};
}Survey Implementation
code
class OnSiteSurvey {
private surveyConfig: SurveyConfig;
private hasShown: boolean = false;
constructor(config: SurveyConfig) {
this.surveyConfig = config;
this.init();
}
private init() {
switch (this.surveyConfig.trigger) {
case 'exit_intent':
this.setupExitIntent();
break;
case 'scroll_depth':
this.setupScrollTrigger();
break;
case 'time_on_page':
this.setupTimeTrigger();
break;
case 'page_load':
this.setupPageLoadTrigger();
break;
}
}
private setupExitIntent() {
document.addEventListener('mouseout', (e) => {
if (e.clientY < 10 && !this.hasShown) {
this.showSurvey();
}
});
}
private setupScrollTrigger() {
let maxScroll = 0;
window.addEventListener('scroll', () => {
const scrollPercent = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
if (scrollPercent > maxScroll) {
maxScroll = scrollPercent;
if (maxScroll >= this.surveyConfig.scrollThreshold && !this.hasShown) {
this.showSurvey();
}
}
}, { passive: true });
}
private setupTimeTrigger() {
setTimeout(() => {
if (!this.hasShown) {
this.showSurvey();
}
}, this.surveyConfig.timeDelay);
}
private setupPageLoadTrigger() {
// Check if user qualifies (returning visitor, specific segment, etc.)
if (this.checkQualification()) {
setTimeout(() => this.showSurvey(), 2000);
}
}
private checkQualification(): boolean {
// Don't show to first-time visitors
const visitCount = parseInt(localStorage.getItem('visit_count') || '0');
if (visitCount < this.surveyConfig.minVisits) return false;
// Don't show if recently shown
const lastShown = localStorage.getItem('survey_last_shown');
if (lastShown) {
const daysSince = (Date.now() - parseInt(lastShown)) / (1000 * 60 * 60 * 24);
if (daysSince < this.surveyConfig.cooldownDays) return false;
}
return true;
}
private showSurvey() {
this.hasShown = true;
localStorage.setItem('survey_last_shown', Date.now().toString());
const modal = this.createSurveyModal();
document.body.appendChild(modal);
// Track impression
this.trackEvent('survey_shown', { survey_id: this.surveyConfig.id });
}
private createSurveyModal(): HTMLElement {
const modal = document.createElement('div');
modal.className = 'survey-modal';
modal.innerHTML = `
<div class="survey-content">
<button class="survey-close" onclick="this.closest('.survey-modal').remove()">×</button>
<h3>${this.surveyConfig.title}</h3>
<p>${this.surveyConfig.description}</p>
<form class="survey-form" onsubmit="window.surveySubmit(event)">
${this.renderQuestions()}
<button type="submit" class="survey-submit">Submit Feedback</button>
</form>
</div>
`;
return modal;
}
private renderQuestions(): string {
return this.surveyConfig.questions.map((q, i) => {
switch (q.type) {
case 'rating':
return `
<div class="survey-question">
<label>${q.text}</label>
<div class="rating-scale">
${[1,2,3,4,5].map(n => `
<label>
<input type="radio" name="q${i}" value="${n}" required>
<span>${n}</span>
</label>
`).join('')}
</div>
</div>
`;
case 'multiple_choice':
return `
<div class="survey-question">
<label>${q.text}</label>
<div class="choices">
${q.options!.map((opt, j) => `
<label>
<input type="radio" name="q${i}" value="${opt}" required>
${opt}
</label>
`).join('')}
</div>
</div>
`;
case 'open_text':
return `
<div class="survey-question">
<label>${q.text}</label>
<textarea name="q${i}" rows="3" placeholder="Your feedback..."></textarea>
</div>
`;
default:
return '';
}
}).join('');
}
private trackEvent(event: string, data: Record<string, any>) {
// Send to analytics
window.gtag?.('event', event, data);
// Send to backend
fetch('/api/analytics/survey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, ...data, timestamp: Date.now() })
});
}
}
// Initialize exit intent survey
new OnSiteSurvey({
id: 'exit_survey_checkout',
trigger: 'exit_intent',
title: 'Wait! Before you go...',
description: 'Help us improve your experience',
minVisits: 2,
cooldownDays: 7,
questions: [
{
type: 'multiple_choice',
text: 'What stopped you from completing checkout?',
options: [
'Just browsing',
'Price too high',
'Shipping costs',
'Need to compare options',
'Technical issue',
'Other'
]
},
{
type: 'open_text',
text: 'Anything else you\'d like to share?'
}
]
});Session Recordings
Recording Setup and Analysis
code
interface SessionRecordingStrategy {
whatToRecord: {
newVisitors: 'First-time user journeys';
abandonedCarts: 'Checkout drop-off analysis';
highValuePages: 'Pricing, signup, checkout';
errorPages: 'Users who encountered errors';
};
privacyConsiderations: {
piiMasking: 'Automatically mask sensitive fields';
consent: 'Clear opt-in for recording';
retention: 'Define data retention period';
excludedElements: 'Password fields, credit cards';
};
analysisFramework: {
rageclicks: 'Frustrated repeated clicking';
deadClicks: 'Clicks on non-interactive elements';
scrollDepth: 'Content engagement patterns';
hesitation: 'Long pauses before actions';
uTurns: 'Navigating back frequently';
};
}
class SessionRecorder {
private events: RecordedEvent[] = [];
private isRecording: boolean = false;
private sessionId: string;
constructor(config: RecorderConfig) {
this.sessionId = this.generateSessionId();
if (this.shouldRecord(config)) {
this.startRecording();
}
}
private shouldRecord(config: RecorderConfig): boolean {
// Sample rate check
if (Math.random() > config.sampleRate) return false;
// Check consent
if (!this.hasConsent()) return false;
// Check if page is in recording list
if (config.includePaths) {
const currentPath = window.location.pathname;
if (!config.includePaths.some(p => currentPath.includes(p))) {
return false;
}
}
return true;
}
private startRecording() {
this.isRecording = true;
// DOM mutations
this.observeDOMMutations();
// Mouse movements (throttled)
this.trackMouseMovements();
// Clicks
this.trackClicks();
// Scrolls
this.trackScrolls();
// Form interactions
this.trackFormInteractions();
// Page visibility
this.trackVisibility();
// Errors
this.trackErrors();
// Send data periodically
setInterval(() => this.sendBatch(), 5000);
// Send on page unload
window.addEventListener('beforeunload', () => this.sendBatch());
}
private trackClicks() {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
this.events.push({
type: 'click',
timestamp: Date.now(),
x: e.clientX,
y: e.clientY,
target: this.getSelector(target),
text: target.textContent?.slice(0, 50)
});
// Detect rage clicks
this.detectRageClick(e.clientX, e.clientY);
});
}
private detectRageClick(x: number, y: number) {
const recentClicks = this.events
.filter(e => e.type === 'click' && Date.now() - e.timestamp < 2000)
.filter(e => Math.abs(e.x! - x) < 30 && Math.abs(e.y! - y) < 30);
if (recentClicks.length >= 3) {
this.events.push({
type: 'rage_click',
timestamp: Date.now(),
x,
y,
clickCount: recentClicks.length
});
}
}
private trackFormInteractions() {
document.querySelectorAll('input, textarea, select').forEach(field => {
const input = field as HTMLInputElement;
// Focus
input.addEventListener('focus', () => {
this.events.push({
type: 'field_focus',
timestamp: Date.now(),
field: this.getFieldIdentifier(input)
});
});
// Blur (field complete)
input.addEventListener('blur', () => {
this.events.push({
type: 'field_blur',
timestamp: Date.now(),
field: this.getFieldIdentifier(input),
hasValue: !!input.value,
valueLength: input.value.length
});
});
// Error state
input.addEventListener('invalid', () => {
this.events.push({
type: 'field_error',
timestamp: Date.now(),
field: this.getFieldIdentifier(input)
});
});
});
}
private trackErrors() {
window.addEventListener('error', (e) => {
this.events.push({
type: 'js_error',
timestamp: Date.now(),
message: e.message,
filename: e.filename,
line: e.lineno
});
});
// Console errors
const originalError = console.error;
console.error = (...args) => {
this.events.push({
type: 'console_error',
timestamp: Date.now(),
message: args.map(a => String(a)).join(' ')
});
originalError.apply(console, args);
};
}
private getSelector(el: HTMLElement): string {
if (el.id) return `#${el.id}`;
if (el.className) return `.${el.className.split(' ').join('.')}`;
return el.tagName.toLowerCase();
}
private getFieldIdentifier(field: HTMLInputElement): string {
return field.name || field.id || field.placeholder || 'unknown';
}
private generateSessionId(): string {
return `rec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private hasConsent(): boolean {
return localStorage.getItem('recording_consent') === 'true';
}
private async sendBatch() {
if (this.events.length === 0) return;
const batch = this.events.splice(0, this.events.length);
await fetch('/api/recordings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: this.sessionId,
events: batch,
url: window.location.href,
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
}),
keepalive: true
});
}
private observeDOMMutations() {
// Implementation for DOM change tracking
}
private trackMouseMovements() {
// Throttled mouse movement tracking
}
private trackScrolls() {
// Scroll position tracking
}
private trackVisibility() {
// Page visibility changes
}
}Heatmap Analysis
Click Heatmap Data Collection
code
class HeatmapCollector {
private clicks: ClickData[] = [];
private pageIdentifier: string;
constructor() {
this.pageIdentifier = this.getPageIdentifier();
this.initClickTracking();
this.initScrollTracking();
}
private initClickTracking() {
document.addEventListener('click', (e) => {
const scrollX = window.scrollX;
const scrollY = window.scrollY;
this.clicks.push({
x: e.clientX + scrollX,
y: e.clientY + scrollY,
relativeX: e.clientX / window.innerWidth,
relativeY: (e.clientY + scrollY) / document.body.scrollHeight,
element: this.getElementPath(e.target as Element),
timestamp: Date.now(),
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
});
// Batch send every 10 clicks or on page unload
if (this.clicks.length >= 10) {
this.sendClicks();
}
});
window.addEventListener('beforeunload', () => this.sendClicks());
}
private initScrollTracking() {
let maxScroll = 0;
const scrollData: ScrollData[] = [];
const trackScroll = this.throttle(() => {
const scrollPercent = Math.round(
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
);
if (scrollPercent > maxScroll) {
maxScroll = scrollPercent;
}
scrollData.push({
percent: scrollPercent,
timestamp: Date.now()
});
}, 500);
window.addEventListener('scroll', trackScroll, { passive: true });
window.addEventListener('beforeunload', () => {
this.sendScrollData(maxScroll, scrollData);
});
}
private getElementPath(element: Element): string {
const path: string[] = [];
let current: Element | null = element;
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += `#${current.id}`;
path.unshift(selector);
break;
} else if (current.className) {
const classes = current.className.trim().split(/\s+/).slice(0, 2).join('.');
selector += `.${classes}`;
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(' > ');
}
private getPageIdentifier(): string {
// Normalize URL for heatmap aggregation
return window.location.pathname.replace(/\/\d+/g, '/:id');
}
private async sendClicks() {
if (this.clicks.length === 0) return;
const batch = this.clicks.splice(0, this.clicks.length);
await fetch('/api/heatmaps/clicks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page: this.pageIdentifier,
clicks: batch,
deviceType: this.getDeviceType()
}),
keepalive: true
});
}
private async sendScrollData(maxScroll: number, data: ScrollData[]) {
await fetch('/api/heatmaps/scroll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page: this.pageIdentifier,
maxScroll,
scrollEvents: data,
deviceType: this.getDeviceType()
}),
keepalive: true
});
}
private getDeviceType(): string {
if (window.innerWidth < 768) return 'mobile';
if (window.innerWidth < 1024) return 'tablet';
return 'desktop';
}
private throttle(fn: Function, delay: number) {
let lastCall = 0;
return (...args: any[]) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
fn(...args);
}
};
}
}
// Initialize
new HeatmapCollector();Heatmap Visualization
code
class HeatmapRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
constructor(container: HTMLElement) {
this.canvas = document.createElement('canvas');
this.canvas.style.cssText = `
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 9999;
`;
this.ctx = this.canvas.getContext('2d')!;
container.appendChild(this.canvas);
}
render(clicks: ClickData[], options: RenderOptions = {}) {
const { radius = 30, blur = 15, maxOpacity = 0.8 } = options;
// Set canvas size
this.canvas.width = document.body.scrollWidth;
this.canvas.height = document.body.scrollHeight;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Calculate max intensity for normalization
const intensityMap = new Map<string, number>();
clicks.forEach(click => {
const key = `${Math.round(click.x / radius)},${Math.round(click.y / radius)}`;
intensityMap.set(key, (intensityMap.get(key) || 0) + 1);
});
const maxIntensity = Math.max(...intensityMap.values());
// Draw heat points
clicks.forEach(click => {
const key = `${Math.round(click.x / radius)},${Math.round(click.y / radius)}`;
const intensity = (intensityMap.get(key) || 1) / maxIntensity;
this.drawHeatPoint(click.x, click.y, radius, intensity * maxOpacity);
});
// Apply color gradient
this.applyColorGradient();
}
private drawHeatPoint(x: number, y: number, radius: number, intensity: number) {
const gradient = this.ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(255, 0, 0, ${intensity})`);
gradient.addColorStop(1, 'rgba(255, 0, 0, 0)');
this.ctx.beginPath();
this.ctx.fillStyle = gradient;
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.fill();
}
private applyColorGradient() {
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const data = imageData.data;
// Color palette: blue -> cyan -> green -> yellow -> red
const palette = this.createColorPalette();
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
const colorIndex = Math.min(255, Math.floor((alpha / 255) * 255));
const color = palette[colorIndex];
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
data[i + 3] = Math.min(200, alpha * 2); // Boost visibility
}
}
this.ctx.putImageData(imageData, 0, 0);
}
private createColorPalette(): Array<{r: number; g: number; b: number}> {
const palette: Array<{r: number; g: number; b: number}> = [];
for (let i = 0; i < 256; i++) {
const ratio = i / 255;
let r, g, b;
if (ratio < 0.25) {
// Blue to Cyan
const t = ratio / 0.25;
r = 0;
g = Math.round(255 * t);
b = 255;
} else if (ratio < 0.5) {
// Cyan to Green
const t = (ratio - 0.25) / 0.25;
r = 0;
g = 255;
b = Math.round(255 * (1 - t));
} else if (ratio < 0.75) {
// Green to Yellow
const t = (ratio - 0.5) / 0.25;
r = Math.round(255 * t);
g = 255;
b = 0;
} else {
// Yellow to Red
const t = (ratio - 0.75) / 0.25;
r = 255;
g = Math.round(255 * (1 - t));
b = 0;
}
palette.push({ r, g, b });
}
return palette;
}
}User Interviews
Interview Framework
code
interface UserInterviewGuide {
preparation: {
objectives: string[];
targetParticipants: ParticipantCriteria;
questionsToAnswer: string[];
materialsNeeded: string[];
};
structure: {
introduction: {
duration: '5 minutes';
goals: ['Build rapport', 'Explain process', 'Get consent'];
};
warmUp: {
duration: '5 minutes';
goals: ['Easy questions', 'Understand context'];
};
mainQuestions: {
duration: '30-40 minutes';
goals: ['Deep exploration', 'Behavior understanding'];
};
wrapUp: {
duration: '5-10 minutes';
goals: ['Summary', 'Additional thoughts', 'Thank participant'];
};
};
questionTypes: {
behavioral: 'Tell me about the last time you...';
contextual: 'Walk me through how you typically...';
comparative: 'How does this compare to...';
hypothetical: 'If you could change one thing...';
probe: 'Tell me more about that...';
};
}
const croInterviewScript: InterviewScript = {
introduction: `
Thank you for taking the time to speak with me today.
I'm researching how people like you [use our product/make purchase decisions].
There are no right or wrong answers - I'm genuinely curious about your experience.
Everything you share will be kept confidential.
Do you have any questions before we begin?
`,
warmUp: [
"Can you tell me a bit about your role and what you do day-to-day?",
"How long have you been using [product category]?",
"What tools or solutions do you currently use for [problem area]?"
],
mainQuestions: {
discoveryPhase: [
"Think back to when you first started looking for a solution like ours. What prompted that search?",
"What was the first thing you did when you decided you needed to solve this problem?",
"What alternatives did you consider? What made you look at each one?"
],
evaluationPhase: [
"Walk me through your decision-making process. What factors were most important?",
"What concerns or hesitations did you have during the process?",
"Who else was involved in the decision? How did their input affect things?"
],
experiencePhase: [
"Describe your experience with our [website/signup process/checkout].",
"Was there anything confusing or frustrating about the process?",
"What almost stopped you from [signing up/purchasing]?"
],
improvementPhase: [
"If you could wave a magic wand, what would you change about the experience?",
"What information did you wish you had that wasn't available?",
"How could we have made your decision easier?"
]
},
probes: [
"Tell me more about that...",
"Why do you think that is?",
"Can you give me an example?",
"How did that make you feel?",
"What happened next?"
],
closing: `
Thank you so much for your time and insights.
Is there anything else you'd like to share that we haven't covered?
[Explain next steps/compensation if applicable]
`
};Interview Analysis
code
interface InterviewAnalysis {
participant: {
id: string;
segment: string;
context: string;
};
themes: Array<{
theme: string;
quotes: string[];
frequency: number;
sentiment: 'positive' | 'negative' | 'neutral';
}>;
painPoints: Array<{
description: string;
severity: 'high' | 'medium' | 'low';
quotes: string[];
opportunities: string[];
}>;
insights: Array<{
insight: string;
evidence: string[];
actionableRecommendation: string;
priority: 'high' | 'medium' | 'low';
}>;
}
class InterviewAnalyzer {
private transcripts: InterviewTranscript[];
constructor(transcripts: InterviewTranscript[]) {
this.transcripts = transcripts;
}
extractThemes(): Theme[] {
const themes = new Map<string, { quotes: string[]; count: number }>();
this.transcripts.forEach(transcript => {
// Extract key phrases and group by theme
const phrases = this.extractKeyPhrases(transcript.text);
phrases.forEach(phrase => {
const theme = this.categorizePhrase(phrase);
if (!themes.has(theme)) {
themes.set(theme, { quotes: [], count: 0 });
}
themes.get(theme)!.quotes.push(phrase);
themes.get(theme)!.count++;
});
});
return Array.from(themes.entries())
.map(([theme, data]) => ({
theme,
quotes: data.quotes,
frequency: data.count
}))
.sort((a, b) => b.frequency - a.frequency);
}
identifyPainPoints(): PainPoint[] {
const painPointPatterns = [
/frustrat(ed|ing)/gi,
/confus(ed|ing)/gi,
/difficult/gi,
/couldn't find/gi,
/didn't understand/gi,
/took too long/gi,
/wish(ed)? I could/gi,
/annoying/gi,
/problem with/gi
];
const painPoints: PainPoint[] = [];
this.transcripts.forEach(transcript => {
const sentences = transcript.text.split(/[.!?]+/);
sentences.forEach(sentence => {
painPointPatterns.forEach(pattern => {
if (pattern.test(sentence)) {
painPoints.push({
quote: sentence.trim(),
participant: transcript.participantId,
category: this.categorizePainPoint(sentence)
});
}
});
});
});
return this.consolidatePainPoints(painPoints);
}
generateInsights(): Insight[] {
const themes = this.extractThemes();
const painPoints = this.identifyPainPoints();
// Generate insights from patterns
const insights: Insight[] = [];
// Frequency-based insights
themes
.filter(t => t.frequency >= 3) // Mentioned by 3+ participants
.forEach(theme => {
insights.push({
type: 'pattern',
insight: `${theme.frequency} participants mentioned "${theme.theme}"`,
evidence: theme.quotes.slice(0, 3),
recommendation: this.generateRecommendation(theme)
});
});
// Pain point insights
const groupedPainPoints = this.groupBy(painPoints, 'category');
Object.entries(groupedPainPoints).forEach(([category, points]) => {
if (points.length >= 2) {
insights.push({
type: 'pain_point',
insight: `Multiple users experienced friction with ${category}`,
evidence: points.slice(0, 3).map(p => p.quote),
recommendation: `Address ${category} issues to reduce friction`
});
}
});
return insights;
}
private extractKeyPhrases(text: string): string[] {
// Simplified key phrase extraction
return text
.split(/[.!?]+/)
.filter(s => s.length > 20)
.map(s => s.trim());
}
private categorizePhrase(phrase: string): string {
// Simplified categorization
const categories = {
pricing: /price|cost|expensive|cheap|value/i,
usability: /easy|hard|confus|understand|find/i,
trust: /trust|secure|safe|reliable/i,
speed: /fast|slow|quick|time/i,
support: /help|support|contact|question/i
};
for (const [category, pattern] of Object.entries(categories)) {
if (pattern.test(phrase)) return category;
}
return 'other';
}
private categorizePainPoint(sentence: string): string {
// Similar to categorizePhrase but for pain points
return this.categorizePhrase(sentence);
}
private consolidatePainPoints(points: PainPoint[]): PainPoint[] {
// Group similar pain points
return points;
}
private generateRecommendation(theme: Theme): string {
// Generate actionable recommendation based on theme
return `Investigate and address ${theme.theme} based on user feedback`;
}
private groupBy<T>(arr: T[], key: keyof T): Record<string, T[]> {
return arr.reduce((acc, item) => {
const k = String(item[key]);
if (!acc[k]) acc[k] = [];
acc[k].push(item);
return acc;
}, {} as Record<string, T[]>);
}
}Usability Testing
Test Script Template
code
interface UsabilityTestScript {
task: {
name: string;
scenario: string;
successCriteria: string[];
timeLimit?: number;
};
preTaskQuestions: string[];
postTaskQuestions: string[];
metrics: {
taskCompletion: boolean;
timeToComplete: number;
errorCount: number;
assistanceNeeded: boolean;
satisfactionRating: number;
};
}
const checkoutUsabilityTest: UsabilityTestScript = {
task: {
name: 'Complete a purchase',
scenario: `
Imagine you're shopping for a new pair of running shoes.
You've found a pair you like that costs $120.
Please complete the purchase process using the test credit card provided.
`,
successCriteria: [
'Successfully adds item to cart',
'Completes checkout form',
'Reaches order confirmation page'
],
timeLimit: 300 // 5 minutes
},
preTaskQuestions: [
'Have you purchased running shoes online before?',
'How often do you shop online?'
],
postTaskQuestions: [
'On a scale of 1-7, how easy was this task to complete?',
'Was there anything confusing about the process?',
'What, if anything, would you change about this experience?'
],
metrics: {
taskCompletion: true,
timeToComplete: 0,
errorCount: 0,
assistanceNeeded: false,
satisfactionRating: 0
}
};Research Insights Dashboard
code
-- User research insights aggregation
CREATE VIEW user_research_insights AS
WITH survey_summary AS (
SELECT
survey_id,
question_text,
answer_text,
COUNT(*) as response_count,
ROUND(COUNT(*)::numeric / SUM(COUNT(*)) OVER (PARTITION BY survey_id, question_text) * 100, 1) as percentage
FROM survey_responses
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY survey_id, question_text, answer_text
),
session_issues AS (
SELECT
'rage_click' as issue_type,
page_path,
COUNT(*) as occurrence_count
FROM session_events
WHERE event_type = 'rage_click'
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY page_path
UNION ALL
SELECT
'error' as issue_type,
page_path,
COUNT(*) as occurrence_count
FROM session_events
WHERE event_type = 'js_error'
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY page_path
),
scroll_depth AS (
SELECT
page_path,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY max_scroll) as median_scroll,
AVG(max_scroll) as avg_scroll
FROM scroll_tracking
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY page_path
)
SELECT
'Survey Response' as insight_type,
question_text as context,
answer_text as detail,
response_count as metric_value,
percentage as percentage
FROM survey_summary
WHERE percentage > 10
UNION ALL
SELECT
'UX Issue' as insight_type,
page_path as context,
issue_type as detail,
occurrence_count as metric_value,
NULL as percentage
FROM session_issues
WHERE occurrence_count > 10
UNION ALL
SELECT
'Scroll Depth' as insight_type,
page_path as context,
'Median scroll: ' || ROUND(median_scroll, 0) || '%' as detail,
median_scroll as metric_value,
NULL as percentage
FROM scroll_depth
WHERE median_scroll < 50
ORDER BY metric_value DESC;Research Best Practices
code
## User Research Checklist for CRO
### Survey Best Practices
□ Keep surveys under 5 questions
□ Use specific, unbiased language
□ Include one open-ended question
□ Test survey logic before launch
□ Set appropriate sampling and timing
### Interview Guidelines
□ Recruit 5-8 participants per segment
□ Prepare structured discussion guide
□ Record sessions (with consent)
□ Take notes on observations, not just words
□ Look for patterns across interviews
### Session Recording Analysis
□ Watch at least 20-30 sessions
□ Segment by behavior (converters vs non-converters)
□ Document rage clicks and dead clicks
□ Note form field abandonment patterns
□ Track error encounters
### Heatmap Analysis
□ Analyze by device type separately
□ Compare high-converting vs low-converting pages
□ Check if CTAs are in click hotspots
□ Verify scroll depth reaches key content
□ Identify unexpected click patterns
### Turning Research into Action
□ Synthesize findings across all sources
□ Prioritize issues by frequency and severity
□ Create hypotheses from insights
□ Quantify potential impact
□ Document evidence for each recommendationNext: Optimize your Forms & CTAs with research-backed best practices.