I reviewed pull requests from 50 mid-level developers and 50 senior developers over the past year. The technical differences were not what I expected.
Seniors did not write more clever code. They wrote code that solved different problems.
Here are the concrete technical patterns that separate the levels.
Problem Framing Before Code
A mid-level developer receives this ticket: "Add caching to the user profile endpoint."
They write this:
async function getUserProfile(userId: string) {
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
const user = await db.users.findById(userId);
await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600);
return user;
}
Works correctly. Ships fast. Task complete.
A senior developer asks questions first. What problem are we solving? Is latency the issue or database load? What is the read/write ratio? How stale can data be? What happens on cache failure?
Then they might write this:
type CacheStrategy = 'write-through' | 'write-behind' | 'read-through';
interface CacheConfig {
ttlSeconds: number;
staleWhileRevalidate: boolean;
fallbackOnError: boolean;
invalidationEvents: string[];
}
const USER_CACHE_CONFIG: CacheConfig = {
ttlSeconds: 300,
staleWhileRevalidate: true,
fallbackOnError: true,
invalidationEvents: ['user.updated', 'user.deleted'],
};
async function getUserProfile(
userId: string,
options: { allowStale?: boolean } = {}
): Promise<UserProfile> {
const cacheKey = `user:profile:${userId}`;
try {
const cached = await cache.get<UserProfile>(cacheKey);
if (cached.hit) {
if (!cached.stale || options.allowStale) {
return cached.value;
}
// Stale: return immediately, refresh in background
refreshInBackground(cacheKey, () => fetchUserProfile(userId));
return cached.value;
}
} catch (error) {
logger.warn('Cache read failed', { userId, error });
// Continue to database if cache fails
}
return fetchUserProfile(userId);
}
More code. But the senior version handles cache failures gracefully, supports stale-while-revalidate pattern, has clear configuration, and documents the invalidation strategy.
The difference is not complexity for its own sake. It is anticipating production realities.
Error Handling Depth
Mid-level error handling:
async function processPayment(orderId: string) {
try {
const order = await getOrder(orderId);
const result = await paymentProvider.charge(order.amount);
await updateOrderStatus(orderId, 'paid');
return result;
} catch (error) {
console.error('Payment failed:', error);
throw new Error('Payment processing failed');
}
}
Senior error handling:
class PaymentError extends Error {
constructor(
message: string,
public readonly code: PaymentErrorCode,
public readonly retryable: boolean,
public readonly orderId: string,
public readonly cause?: Error
) {
super(message);
this.name = 'PaymentError';
}
}
async function processPayment(
orderId: string,
idempotencyKey: string
): Promise<PaymentResult> {
const order = await getOrder(orderId);
if (!order) {
throw new PaymentError(
'Order not found',
'ORDER_NOT_FOUND',
false,
orderId
);
}
if (order.status === 'paid') {
logger.info('Order already paid, returning cached result', { orderId });
return order.paymentResult;
}
let chargeResult: ChargeResult;
try {
chargeResult = await paymentProvider.charge({
amount: order.amount,
currency: order.currency,
idempotencyKey,
metadata: { orderId },
});
} catch (error) {
if (error instanceof ProviderTimeoutError) {
// Unknown state: payment might have succeeded
await markOrderForReconciliation(orderId);
throw new PaymentError(
'Payment status unknown, queued for reconciliation',
'TIMEOUT_UNKNOWN_STATE',
false,
orderId,
error
);
}
if (error instanceof ProviderDeclinedError) {
throw new PaymentError(
'Payment declined',
'DECLINED',
false,
orderId,
error
);
}
throw new PaymentError(
'Payment provider error',
'PROVIDER_ERROR',
true,
orderId,
error
);
}
await updateOrderStatus(orderId, 'paid', chargeResult);
return {
success: true,
transactionId: chargeResult.transactionId,
};
}
The senior version handles idempotency for retry safety. It distinguishes retryable from non-retryable errors. It handles the dangerous timeout case where payment state is unknown. It provides structured errors that calling code can act on.
Type Design That Prevents Bugs
Mid-level types:
interface User {
id: string;
email: string;
role: string;
status: string;
createdAt: Date;
}
Senior types:
type UserId = string & { readonly brand: unique symbol };
type Email = string & { readonly brand: unique symbol };
type UserRole = 'admin' | 'editor' | 'viewer';
type UserStatus = 'active' | 'suspended' | 'pending_verification';
interface User {
readonly id: UserId;
readonly email: Email;
role: UserRole;
status: UserStatus;
readonly createdAt: Date;
}
// Constructors that validate
function createUserId(id: string): UserId {
if (!UUID_REGEX.test(id)) {
throw new ValidationError('Invalid user ID format');
}
return id as UserId;
}
function createEmail(email: string): Email {
if (!EMAIL_REGEX.test(email)) {
throw new ValidationError('Invalid email format');
}
return email.toLowerCase() as Email;
}
// Now this is a compile-time error:
const user: User = {
id: 'not-a-uuid', // Error: string not assignable to UserId
email: 'UPPER@TEST.COM', // Error: string not assignable to Email
role: 'superadmin', // Error: not in UserRole union
status: 'deleted', // Error: not in UserStatus union
createdAt: new Date(),
};
Branded types prevent entire categories of bugs. You cannot accidentally pass an order ID where a user ID is expected. Invalid states become unrepresentable.
Database Query Patterns
Mid-level query:
async function getActiveUsers() {
return db.query('SELECT * FROM users WHERE status = $1', ['active']);
}
async function getUserOrders(userId: string) {
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
const orders = await db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
return { user: user.rows[0], orders: orders.rows };
}
Senior query:
async function getActiveUsersWithRecentOrders(
pagination: { limit: number; cursor?: string }
): Promise<PaginatedResult<UserWithOrders>> {
const query = `
WITH active_users AS (
SELECT id, email, created_at
FROM users
WHERE status = 'active'
AND ($2::text IS NULL OR id > $2)
ORDER BY id
LIMIT $1 + 1
)
SELECT
au.*,
COALESCE(
json_agg(
json_build_object(
'id', o.id,
'total', o.total,
'status', o.status
) ORDER BY o.created_at DESC
) FILTER (WHERE o.id IS NOT NULL AND o.created_at > NOW() - INTERVAL '30 days'),
'[]'
) as recent_orders
FROM active_users au
LEFT JOIN orders o ON o.user_id = au.id
GROUP BY au.id, au.email, au.created_at
ORDER BY au.id
`;
const result = await db.query(query, [
pagination.limit,
pagination.cursor,
]);
const hasMore = result.rows.length > pagination.limit;
const items = result.rows.slice(0, pagination.limit);
return {
items,
hasMore,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
}
The senior version eliminates N+1 queries. Uses cursor pagination instead of offset for stable performance. Fetches only recent orders to bound result size. Returns pagination metadata for client convenience.
Component Architecture
Mid-level React component:
function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, [userId]);
useEffect(() => {
fetch(`/api/users/${userId}/orders`)
.then(res => res.json())
.then(data => setOrders(data));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
<h1>{user.name}</h1>
{orders.map(order => <OrderCard key={order.id} order={order} />)}
</div>
);
}
Senior React component:
// Separate data fetching from presentation
function useUserDashboard(userId: string) {
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => userApi.getById(userId),
staleTime: 5 * 60 * 1000,
});
const ordersQuery = useQuery({
queryKey: ['user', userId, 'orders'],
queryFn: () => orderApi.getByUserId(userId),
enabled: !!userQuery.data,
});
return {
user: userQuery.data,
orders: ordersQuery.data ?? [],
isLoading: userQuery.isLoading,
isError: userQuery.isError || ordersQuery.isError,
error: userQuery.error || ordersQuery.error,
};
}
// Presentation component is pure and testable
function UserDashboardView({
user,
orders,
onOrderClick,
}: UserDashboardViewProps) {
return (
<div className="dashboard">
<UserHeader user={user} />
<OrderList orders={orders} onOrderClick={onOrderClick} />
</div>
);
}
// Container handles orchestration
function UserDashboard({ userId }: { userId: string }) {
const { user, orders, isLoading, isError, error } = useUserDashboard(userId);
const navigate = useNavigate();
if (isLoading) return <DashboardSkeleton />;
if (isError) {
return (
<ErrorBoundary
error={error}
onRetry={() => queryClient.invalidateQueries(['user', userId])}
/>
);
}
return (
<UserDashboardView
user={user}
orders={orders}
onOrderClick={(orderId) => navigate(`/orders/${orderId}`)}
/>
);
}
The senior version separates concerns. Data fetching is isolated in a custom hook. Presentation component is pure and easy to test. Container handles coordination. Uses established patterns like React Query with proper caching configuration.
The Actual Gap
These examples show the real technical gap between levels.
Mid-level code works. Senior code works and handles failure gracefully. Works and scales predictably. Works and other developers can understand it six months later. Works and prevents entire categories of bugs through type design.
The difference is not knowing more APIs or writing more clever algorithms. It is thinking about different problems entirely.
Mid-level thinks about making features work.
Senior thinks about making systems reliable, maintainable, and safe.
This thinking gap is why learning more frameworks does not lead to promotion. The problems seniors solve are not framework problems. They are system problems, people problems, and future problems.
Start thinking about those, and the code will follow.
More on the career gap between levels: jsgurujobs.com
Top comments (0)