Work with Shubham
Connect with Shubham Jha
Available for senior engineering roles, technical consulting, and product advisory. I specialise in React, Next.js, and full-stack architecture for global-scale platforms.
Start a projectWork with Shubham
Available for senior engineering roles, technical consulting, and product advisory. I specialise in React, Next.js, and full-stack architecture for global-scale platforms.
Start a project
The onboarding flow had 11 steps. The product, a B2B SaaS workspace, required users to complete all 11 before they could do anything meaningful. Week-one activation (the share of trial users who reached the product's core value) was 23%. Support tickets arrived in batches every Monday morning: "I can't figure out how to set up my account." "The button doesn't do anything." "I gave up after step four."
Six months of engineering work had gone into the product. The design had received exactly one pass from a developer following a rough mockup. Nobody had watched a real user try to use it.
We spent four weeks on UI/UX: cutting onboarding from 11 steps to 5, making the primary action on every screen unmissable, fixing components that showed a blank screen when data was loading, and making the core flows work from a keyboard. Week-one activation went from 23% to 38%. Support tickets dropped 46%. Average time-to-value dropped from 14 minutes to 6.
None of those changes added a single feature. They made the existing features comprehensible.
UI/UX in a SaaS product isn't polish. It's the difference between a product that converts and one that churns. Every design decision maps to a measurable outcome: activation, time-to-value, support volume, retention. This post is about the six patterns that moved those numbers.
Clarity failures are invisible to builders and obvious to users. When you've been staring at a UI for two months, "Submit" means "create the campaign." When a user sees it for the first time, "Submit" means nothing.
The product we inherited had buttons labelled "Submit", "Continue", and "Next" used interchangeably across different flows. Two buttons on the same screen both styled as primary. A form that asked for information users wouldn't have until later in the setup process. Every screen was asking the user to guess.
The fix starts with labels. A button's text should describe what happens when you click it, not the action of clicking. "Submit" becomes "Create Campaign". "Continue" becomes "Save and invite team". "Next" becomes "Set up billing →". One word change per button, repeated across 40 screens, reduced first-session drop-off by 18%.
Hierarchy is the harder problem. Every screen should have exactly one primary action. Three equally-styled buttons and users freeze — they don't know which one matters. A primary calls for action. A secondary provides an escape. A ghost handles edge cases. When everything is primary, nothing is.
// Before: three competing primary buttons — users freeze
<Button onClick={handleSave}>Save</Button>
<Button onClick={handlePreview}>Preview</Button>
<Button onClick={handlePublish}>Publish</Button>
// After: clear hierarchy — one call to action, one escape, one edge case
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={handleSave}>Save draft</Button>
<Button variant="secondary" onClick={handlePreview}>Preview</Button>
<Button variant="primary" onClick={handlePublish}>
Publish campaign →
</Button>
</div>
The most underrated fix is microcopy. It's the short text at decision points: below a form field, next to a checkbox, inside an empty state. It doesn't need to be clever. It needs to answer the question the user is about to ask. A password field that says "Minimum 8 characters, one uppercase" prevents the error before it happens. A data-sharing checkbox that says "Your data is never sold or shared with third parties" converts better than one that says "Receive marketing emails". Microcopy lives at the exact moment of hesitation. It's the cheapest conversion optimization available.
Clarity also means reducing what you ask for. The 11-step onboarding asked for company size, industry, team structure, integration preferences, and notification settings, all before the user had done anything. We cut it to: name the workspace, invite one teammate, connect one data source. Everything else defaulted to sensible values and could be changed later. Activation went up because users reached the product before they ran out of patience.
Inconsistency has a tax. A team without a design system makes the same spacing decision 300 times across a codebase. A user who encounters a button that behaves differently on page 3 than on page 1 hesitates. A QA engineer who doesn't know which of four slightly-different modal implementations is canonical tests all four. None of this is dramatic. It compounds into a product that feels unreliable and a team that moves slower than it should.
A design system is the set of constraints that makes the right decision the default. In practice, it comes down to three layers: a token file, a typed component API, and documented state patterns. The token file propagates changes. The typed API prevents the wrong variant from compiling. The state patterns enforce that every component handles loading, empty, and error before it ships. Each layer closes a different failure mode.
A design token is a named value that propagates through every component. Change the token, every component updates. Without tokens, a brand color change means touching hundreds of files. With tokens, it means one change in one place.
/* globals.css — semantic tokens that describe purpose, not appearance */
:root {
/* Color — OKLCH for perceptual uniformity */
--color-primary: oklch(45% 0.2 264);
--color-primary-hover: oklch(40% 0.22 264);
--color-destructive: oklch(50% 0.22 30);
--color-surface: oklch(99% 0.005 264);
--color-surface-raised: oklch(97% 0.008 264);
--color-border: oklch(88% 0.01 264);
--color-text: oklch(15% 0.01 264);
--color-muted: oklch(50% 0.01 264);
/* Spacing scale (4px base) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Typography */
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--leading-tight: 1.25;
--leading-normal: 1.5;
}
A typed component API makes the right decision the only option. When variant is 'primary' | 'secondary' | 'ghost' | 'destructive', the wrong variant doesn't compile. When size is 'sm' | 'md' | 'lg', there's no improvised "large-ish" variant that appears when a developer needs something slightly bigger.
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
children: React.ReactNode
onClick?: () => void
type?: 'button' | 'submit' | 'reset'
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
children,
onClick,
type = 'button',
}: ButtonProps) {
return (
<button
type={type}
onClick={onClick}
disabled={loading}
aria-busy={loading}
className={cn(buttonVariants({ variant, size }), loading && 'cursor-not-allowed opacity-70')}
>
{loading ? <Spinner size="sm" aria-hidden /> : children}
</button>
)
}
The component handles loading state explicitly. No relying on callers to disable the button, add a spinner, and set aria-busy correctly in three separate places. The right behavior is baked in once.
For most teams, shadcn/ui plus a token file gets you here without building from scratch. The components are yours to own and modify, with no black-box dependency that upgrades and silently breaks your UI. For how a typed component system fits into a larger production architecture, this guide on scalable Next.js apps covers design tokens, component APIs, and the architecture patterns they plug into.
Consistency doesn't mean uniformity. It means users can form accurate predictions. If modals always close on Escape and always return focus to the trigger, users stop thinking about how the UI works and start thinking about their task. Making those predictions reliable for every user, including those navigating by keyboard or assistive technology, is the next constraint.
Accessibility is not a compliance feature. It's a product quality signal with SEO side effects. Every accessibility improvement (semantic HTML, keyboard navigation, readable contrast) makes the product better for every user, not just users with disabilities.
The SaaS products I audit fail in the same places. Keyboard navigation that dead-ends at an interactive component and leaves the user stuck. Focus that disappears when a modal closes — for screen reader users, that's not subtle, it breaks the flow completely. Form errors that say "invalid input" instead of identifying what's wrong and how to fix it. None of these are edge cases. They're the first things a keyboard or screen reader user encounters.
Every interactive element should be reachable with Tab, operable with Enter or Space, and dismissible with Escape. WCAG 2.2 tightened the focus visible requirement to 3:1 contrast ratio for focus indicators, up from 2.1's standard. If you've disabled outline styles globally, your keyboard users are navigating blind.
/* Style focus indicators — don't remove them */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 2px;
}
/* Visible for keyboard navigation, hidden for mouse — best of both */
:focus:not(:focus-visible) {
outline: none;
}
When a dialog opens, focus must move inside it. When it closes, focus must return to the element that triggered it. If a user opens a "Delete workspace" dialog from a button, completes the action, and focus disappears to the top of the document, they've lost their place. For screen reader users that's not subtle. It breaks the flow completely.
import * as Dialog from '@radix-ui/react-dialog'
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel: string
onConfirm: () => void
loading?: boolean
destructive?: boolean
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
onConfirm,
loading,
destructive,
}: ConfirmDialogProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-xl shadow-xl p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-semibold text-foreground">
{title}
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted">
{description}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-3">
<Dialog.Close asChild>
<Button variant="ghost">Cancel</Button>
</Dialog.Close>
<Button
variant={destructive ? 'destructive' : 'primary'}
onClick={onConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
Radix UI's Dialog.Content traps focus within the dialog and returns it to the trigger on close. The component handles correct aria-modal, aria-labelledby, and aria-describedby attributes automatically. You get WCAG-compliant focus management without writing a single line of focus trap code.
Form errors should identify the field by name and describe the fix. "Invalid input" is useless. "Email address must be in the format name@company.com" is actionable. Screen readers announce form errors through aria-live regions; if your error messages render without one, screen reader users never hear them.
<div>
<label htmlFor="email" className="block text-sm font-medium">
Work email
</label>
<input
id="email"
type="email"
aria-describedby={error ? 'email-error' : undefined}
aria-invalid={!!error}
className={cn('mt-1 w-full rounded-md border px-3 py-2', error && 'border-destructive')}
/>
{error && (
<p id="email-error" role="alert" className="mt-1 text-sm text-destructive">
{error}
</p>
)}
</div>
role="alert" causes screen readers to announce the error as soon as it appears. aria-invalid communicates that the field has a problem. aria-describedby links the field to the error message so the relationship is explicit to assistive technology. These three attributes together cover the most common screen reader failure mode in SaaS forms.
Every data-driven component in a SaaS product has five states: loading, empty, error, success, and disabled. Most get built in the success state and shipped. The other four are added reactively when a user files a ticket.
This is the demo happy path problem. During development, data loads in milliseconds, there's always data to display, and errors don't happen. In production, users are on slow connections, their accounts are empty on day one, and backend errors occur. The screens they see in those moments are the first impression of your product's reliability.
The pattern that prevents this is modelling async state as a discriminated union rather than three boolean flags. isLoading, isError, and data give you eight possible combinations, of which only three are valid. TypeScript won't stop you from setting isLoading: true and data: someUser simultaneously. A discriminated union makes the invalid combinations unrepresentable at the type level.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string }
function UserTable({ state, onRetry }: { state: AsyncState<User[]>; onRetry: () => void }) {
if (state.status === 'loading') {
return <TableSkeleton rows={5} />
}
if (state.status === 'error') {
return (
<ErrorState
title="Couldn't load team members"
message={state.message}
action={{ label: 'Try again', onClick: onRetry }}
/>
)
}
if (state.status === 'success' && state.data.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="h-8 w-8" />}
title="No team members yet"
description="Invite your team to start collaborating."
action={{ label: 'Invite team members', onClick: openInviteModal }}
/>
)
}
if (state.status === 'success') {
return <DataTable data={state.data} columns={userColumns} />
}
return null
}
TypeScript now enforces that state.data only exists when status === 'success'. Accessing it in the loading branch is a compile error, not a runtime crash. Every state is handled explicitly before the component ships. For the broader hook and TypeScript patterns that make this approach consistent across a codebase, this guide on React hooks and TypeScript covers how to enforce state discipline at scale.
Skeleton loaders beat spinners for most SaaS data-fetching contexts. A spinner says "wait." A skeleton says "this is where your content will appear." It reduces perceived wait time and prevents layout shift when content loads into an unprepared space.
The key: match the skeleton's dimensions to the loaded content. A skeleton that's 80px tall with content that loads at 160px still causes layout shift. It just delays it by 300ms. Measure the loaded state first, then build the skeleton to match it.
Use a spinner for short, indeterminate operations: saving, uploading, submitting. Use skeletons for data fetches that take more than 200ms and have a predictable shape.
Performance is a design decision as much as an engineering one. Animations that run at 60fps on a developer's MacBook render at 20fps on a mid-range Android. Images without width and height attributes cause the page to jump when they appear. Fonts that load after the initial paint cause text to reflow in ways that feel broken even when they're technically working. These are design choices. Every unnecessary animation, unoptimized image, or font-triggered reflow shows up in both Lighthouse scores and conversion rates.
Every animation in a SaaS product should communicate something. The test I use: could you remove this animation and lose information? If yes, keep it. If no, cut it.
State transitions earn their keep. A button that shows a spinner while loading tells the user "I received your click" — without it, users click twice and submit duplicate forms. A panel that slides in from the right tells them where it lives relative to the current screen. A modal that scales up from its trigger communicates "this is layered above, not replacing." A form field that shakes on invalid submission draws attention to the problem in a way color change alone can't — especially for users who don't perceive red as distinct.
List items that load in staggered sequence reduce perceived wait time on slow connections by making the page feel active rather than frozen.
Everything else — hover animations on static cards, decorative entrance effects, parallax — costs runtime on low-end devices without communicating anything. An animation that only runs on your developer MacBook isn't a product feature.
// Purposeful: communicates loading state without blocking interaction
function SubmitButton({ loading, children }: { loading: boolean; children: React.ReactNode }) {
return (
<button
disabled={loading}
aria-busy={loading}
className={cn(
'relative flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity',
loading && 'cursor-not-allowed opacity-70'
)}
>
{loading && (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden
/>
)}
<span aria-live="polite">{loading ? 'Saving...' : children}</span>
</button>
)
}
aria-live="polite" on the button text means screen readers announce the state change from "Save changes" to "Saving..." without interrupting other announcements.
Optimistic UI removes the 200–400ms lag on actions users repeat dozens of times per session: toggling a status, starring a record, archiving an item. Instead of waiting for the server, you update the UI immediately and revert if the server rejects.
function useOptimisticToggle(
id: string,
initialValue: boolean,
serverAction: (id: string, value: boolean) => Promise<void>
) {
const [optimisticValue, setOptimisticValue] = useState(initialValue)
const [isPending, startTransition] = useTransition()
const toggle = () => {
const next = !optimisticValue
setOptimisticValue(next) // update immediately — no waiting
startTransition(async () => {
try {
await serverAction(id, next)
} catch {
setOptimisticValue(!next) // revert on failure
toast.error("Change didn't save. Try again.")
}
})
}
return { value: optimisticValue, toggle, isPending }
}
Use it for low-risk, reversible, high-frequency actions. Avoid it for deletes, payments, or anything where the server might reject the request, because the user needs to know the outcome before moving on. For the connection between UI design decisions and Core Web Vitals scores (LCP, CLS, INP), this post on Next.js Core Web Vitals covers how performance-aware design choices propagate into measurable ranking signals.
These four patterns come up in every SaaS product I've worked on that retains users past 90 days. They don't show up much in general UX writing because they're specific to products people use as part of their job.
A generic product tour wastes the one moment in a user's experience where they're most motivated to learn. A user who signed up for a project management tool doesn't need a tour of every feature. They need to create their first project, add a task, and understand what the product does. Everything else can wait.
Intent-based onboarding asks one question up front: "What are you here to do?" Then it routes users to the setup path that fits. A developer evaluating an API monitoring tool during a production incident has nothing in common with a team lead doing quarterly planning research. Same product, two different first-run experiences, very different activation rates.
The implementation is simpler than most teams expect: a short branching flow at signup that sets a user.onboardingIntent field, which your onboarding component reads to determine which steps to show. A switch statement on intent is the right abstraction. Don't build a generic flow engine for this.
Show users the minimum they need for the current task. Surface advanced options only when they're relevant. Sensible defaults for everything, expandable sections for configuration, contextual options inline rather than buried in settings.
The rule I keep coming back to: a user in the middle of a task should never have to leave it to find a setting.
function CreateProjectForm() {
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<form>
<Field label="Project name" required />
<Field label="Team" type="select" defaultValue="current-team" />
<button
type="button"
className="mt-4 flex items-center gap-1 text-sm text-muted"
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
aria-controls="advanced-options"
>
<ChevronIcon className={cn('h-4 w-4 transition-transform', showAdvanced && 'rotate-90')} />
Advanced settings
</button>
{showAdvanced && (
<div id="advanced-options" className="mt-3 space-y-4 border-t pt-4">
<Field label="Project key" hint="Used in task IDs (e.g. PROJ-123)" />
<Field label="Visibility" type="select" defaultValue="team" />
<Field label="Start date" type="date" />
</div>
)}
<Button type="submit" className="mt-6">Create project</Button>
</form>
)
}
aria-expanded and aria-controls communicate the toggle state to screen readers. Advanced fields default to sensible values, so a new user who never opens the section still gets a correctly configured project.
Empty states are the most under-designed component in most SaaS products. "No data" helps no one. An empty state should tell users what the section is for, why it's empty, and what to do about it.
interface EmptyStateProps {
icon: React.ReactNode
title: string
description: string
action?: { label: string; onClick: () => void }
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div
className="flex flex-col items-center justify-center py-16 px-8 text-center"
role="status"
>
<div className="mb-4 opacity-30 text-muted-foreground" aria-hidden>
{icon}
</div>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-1 max-w-xs text-sm text-muted-foreground">{description}</p>
{action && (
<Button className="mt-6" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
)
}
The empty state on day one, when the account has no data, is your second onboarding moment. Use it. "No integrations yet — connect your first tool to start syncing data." with a primary CTA converts far better than a grey "No integrations found" message.
Users form their opinion of a product's responsiveness in the first few interactions. If saving a record takes 800ms and shows nothing, the product feels slow regardless of what the server is actually doing. Show a loading state immediately. Update the UI before the server confirms where risk is low. Confirm completion with a brief success state rather than silence.
Perceived speed and actual speed are different numbers. You can move one without touching the other.
UX improvements that survive stakeholder debate are the ones attached to numbers. "The modal flow was confusing" loses to "the error state on the invite flow caused 34% of users to abandon the page without completing their action." Both describe the same problem. One gets prioritised in the next sprint.
Four signals track UX quality over time. I'd instrument all four before making any significant changes — the delta is what makes the argument.
Activation rate is the percentage of new trial users who reach the product's core value within week one. This is the number the onboarding redesign moves directly. Anything under 40% in B2B SaaS warrants a focused investigation into where drop-off is happening and why.
Time-to-value is the median time from account creation to the user's first meaningful action: sending a report, creating a project, connecting an integration, whatever the product's "aha moment" is. If it's above 10 minutes, the path to value is too long, too confusing, or asking for too much upfront.
Error rate by flow measures how often users hit validation errors, error states, or dead ends in specific flows. A checkout flow with a 30% error encounter rate doesn't have a payment problem — it has a form design problem. Track this per flow, not globally, so you know exactly where to look.
Support ticket signal is underused. UX issues generate tickets with two shared properties: they're common, and they're confusing enough that the user couldn't resolve them independently. The top ticket category every month is a UX audit waiting to happen. If you're seeing the same three questions every Monday, that's the backlog item.
// Type-safe UX event tracking — schema drift kills analytics data
type UXEvent =
| { name: 'onboarding_step_viewed'; step: number; totalSteps: number }
| { name: 'onboarding_step_completed'; step: number; timeOnStepMs: number }
| { name: 'onboarding_abandoned'; step: number; totalSteps: number }
| { name: 'empty_state_cta_clicked'; section: string }
| { name: 'error_state_retry_clicked'; page: string; errorCode: string }
| { name: 'form_validation_error'; form: string; field: string }
export function trackUXEvent(event: UXEvent) {
analytics.track(event.name, {
...event,
timestamp: Date.now(),
url: window.location.pathname,
})
}
// Instrument every step of high-value flows
function OnboardingStep({ step, total }: { step: number; total: number }) {
const enteredAt = useRef(Date.now())
useEffect(() => {
trackUXEvent({ name: 'onboarding_step_viewed', step, totalSteps: total })
return () => {
trackUXEvent({ name: 'onboarding_abandoned', step, totalSteps: total })
}
}, [step, total])
const handleComplete = () => {
trackUXEvent({
name: 'onboarding_step_completed',
step,
timeOnStepMs: Date.now() - enteredAt.current,
})
}
// ... render
}
The discriminated union on UXEvent enforces correct properties at the type level. form_validation_error requires field. onboarding_step_completed requires timeOnStepMs. Analytics events drift when different developers send the same event under different property names. Typed events prevent the schema inconsistency that makes your data useless six months later.
The data this instrumentation produces is what turns a UX argument into a product priority. You're not saying "the onboarding is too long." You're saying "step 7 has a 58% abandonment rate and users spend an average of 4 minutes on it before leaving." Those are two different conversations.
The redesigned product shipped three weeks after the audit. Week-one activation: 38%. Support tickets: down 46%. Time-to-value: 6 minutes, from 14. The product hadn't changed. The path through it had.
That's the thing about UX in SaaS: you don't always need more features. Sometimes you need the ones you have to be comprehensible. None of those improvements required new engineering. All of them required treating the interface as a product in its own right — not a pass at the end of a sprint.
If your activation rate is sitting in a backlog labelled known issues, the data to make the case is already there. Find your version of the 23% number.
If you're working on a SaaS product and want to connect design decisions to outcomes, browse my project work or reach out directly.
The principles that move activation and retention most: clarity (users should never guess what happens next), consistency (every screen should behave the same way), explicit component states (loading, empty, error, success), and performance-aware design (no layout shifts, purposeful motion only). These aren't aesthetic choices — each maps to a measurable outcome: activation rate, time-to-value, support ticket volume, and retention.
Design systems improve conversion in two ways. First, they make the product feel coherent — users trust a product that behaves consistently and hesitate when it doesn't. Second, they enforce correct component states (loading, error, empty) which prevents the blank screens and confusing states that cause users to abandon. A typed component API means developers can't accidentally omit a loading state or use the wrong button variant — the right choice becomes the only choice.
Progressive disclosure means showing users only what they need for the current task, and revealing advanced options only when they're relevant. In practice: default form fields to the most common value, hide advanced configuration behind an expandable section, and surface contextual options inline rather than forcing users into a settings page. The goal is reducing cognitive load on first use while not blocking power users from accessing everything they need.
The requirements that trip up SaaS products most often: keyboard navigation through all interactive elements (Tab to move, Enter/Space to activate, Escape to close modals), focus indicators visible at 3:1 contrast ratio, dialog components that trap focus and return it to the trigger on close, form fields with explicit label elements (not just placeholder text), and error messages that identify the specific field and describe how to fix the issue. Use Radix UI or @base-ui/react primitives — they handle focus management and ARIA attributes correctly by default.
Every data-driven component needs five states: loading (skeleton matching the content's dimensions), empty (actionable — tells users what to do next, not just 'no data'), error (recoverable — shows what went wrong and offers a retry), success (the filled state with real data), and disabled (visually distinct with aria-disabled). Missing any of these is not a polish issue — it's a bug. Users encounter empty and error states far more often than you expect, especially in the first session.
Four signals that track UX quality: activation rate (percentage of trials who reach core value in week one), time-to-value (median time from signup to first meaningful action), error rate per flow (how often users hit validation errors or dead ends — a proxy for confusing flows), and support ticket categories (UX issues generate tickets that repeat every month). Instrument these before making changes so you can measure the delta. A redesign that lifts activation from 23% to 38% is a business result, not just a design improvement.
Optimistic UI means updating the interface immediately on user action without waiting for the server response, then reverting if the action fails. Use it for low-risk, high-frequency, reversible actions: toggling a status, starring a record, reordering a list. Avoid it for destructive actions (deletes), payments, or anything where the server applies business logic that might reject the action. The pattern eliminates the 200-400ms lag that makes high-frequency interactions feel sluggish but requires careful error handling — the revert must be visible and informative.
Published: Fri Apr 17 2026