DEV Community

JSGuruJobs
JSGuruJobs

Posted on

The Technical Gap Between Mid-Level and Senior JavaScript Developers

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(),
};
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}`)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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)